console_agent 0.9.0 → 0.11.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.
@@ -1,1166 +1,65 @@
1
- require 'readline'
1
+ require 'console_agent/channel/console'
2
+ require 'console_agent/conversation_engine'
2
3
 
3
4
  module ConsoleAgent
4
5
  class Repl
5
6
  def initialize(binding_context)
6
7
  @binding_context = binding_context
7
- @executor = Executor.new(binding_context)
8
- @provider = nil
9
- @context_builder = nil
10
- @context = nil
11
- @history = []
12
- @total_input_tokens = 0
13
- @total_output_tokens = 0
14
- @token_usage = Hash.new { |h, k| h[k] = { input: 0, output: 0 } }
15
- @input_history = []
8
+ @channel = Channel::Console.new
9
+ @engine = ConversationEngine.new(binding_context: binding_context, channel: @channel)
16
10
  end
17
11
 
18
12
  def one_shot(query)
19
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
- console_capture = StringIO.new
21
- exec_result = with_console_capture(console_capture) do
22
- conversation = [{ role: :user, content: query }]
23
- exec_result, code, executed = one_shot_round(conversation)
24
-
25
- # Auto-retry once if execution errored
26
- if executed && @executor.last_error
27
- error_msg = "Code execution failed with error: #{@executor.last_error}"
28
- error_msg = error_msg[0..1000] + '...' if error_msg.length > 1000
29
- conversation << { role: :assistant, content: @_last_result_text }
30
- conversation << { role: :user, content: error_msg }
31
-
32
- $stdout.puts "\e[2m Attempting to fix...\e[0m"
33
- exec_result, code, executed = one_shot_round(conversation)
34
- end
35
-
36
- @_last_log_attrs = {
37
- query: query,
38
- conversation: conversation,
39
- mode: 'one_shot',
40
- code_executed: code,
41
- code_output: executed ? @executor.last_output : nil,
42
- code_result: executed && exec_result ? exec_result.inspect : nil,
43
- executed: executed,
44
- start_time: start_time
45
- }
46
-
47
- exec_result
48
- end
49
-
50
- log_session(@_last_log_attrs.merge(console_output: console_capture.string))
51
-
52
- exec_result
53
- rescue Providers::ProviderError => e
54
- $stderr.puts "\e[31mConsoleAgent Error: #{e.message}\e[0m"
55
- nil
56
- rescue => e
57
- $stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
58
- nil
59
- end
60
-
61
- # Executes one LLM round: send query, display, optionally execute code.
62
- # Returns [exec_result, code, executed].
63
- def one_shot_round(conversation)
64
- result, _ = send_query(nil, conversation: conversation)
65
- track_usage(result)
66
- code = @executor.display_response(result.text)
67
- display_usage(result)
68
- @_last_result_text = result.text
69
-
70
- exec_result = nil
71
- executed = false
72
- has_code = code && !code.strip.empty?
73
-
74
- if has_code
75
- exec_result = if ConsoleAgent.configuration.auto_execute
76
- @executor.execute(code)
77
- else
78
- @executor.confirm_and_execute(code)
79
- end
80
- executed = !@executor.last_cancelled?
81
- end
82
-
83
- [exec_result, has_code ? code : nil, executed]
13
+ @engine.one_shot(query)
84
14
  end
85
15
 
86
16
  def explain(query)
87
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
- console_capture = StringIO.new
89
- with_console_capture(console_capture) do
90
- result, _ = send_query(query)
91
- track_usage(result)
92
- @executor.display_response(result.text)
93
- display_usage(result)
94
-
95
- @_last_log_attrs = {
96
- query: query,
97
- conversation: [{ role: :user, content: query }, { role: :assistant, content: result.text }],
98
- mode: 'explain',
99
- executed: false,
100
- start_time: start_time
101
- }
102
- end
103
-
104
- log_session(@_last_log_attrs.merge(console_output: console_capture.string))
105
-
106
- nil
107
- rescue Providers::ProviderError => e
108
- $stderr.puts "\e[31mConsoleAgent Error: #{e.message}\e[0m"
109
- nil
110
- rescue => e
111
- $stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
112
- nil
17
+ @engine.explain(query)
113
18
  end
114
19
 
115
20
  def init_guide
116
- storage = ConsoleAgent.storage
117
- existing_guide = begin
118
- content = storage.read(ConsoleAgent::GUIDE_KEY)
119
- (content && !content.strip.empty?) ? content.strip : nil
120
- rescue
121
- nil
122
- end
123
-
124
- if existing_guide
125
- $stdout.puts "\e[36m Existing guide found (#{existing_guide.length} chars). Will update.\e[0m"
126
- else
127
- $stdout.puts "\e[36m No existing guide. Exploring the app...\e[0m"
128
- end
129
-
130
- require 'console_agent/tools/registry'
131
- init_tools = Tools::Registry.new(mode: :init)
132
- sys_prompt = init_system_prompt(existing_guide)
133
- messages = [{ role: :user, content: "Explore this Rails application and generate the application guide." }]
134
-
135
- # Temporarily increase timeout — init conversations are large
136
- original_timeout = ConsoleAgent.configuration.timeout
137
- ConsoleAgent.configuration.timeout = [original_timeout, 120].max
138
-
139
- result, _ = send_query_with_tools(messages, system_prompt: sys_prompt, tools_override: init_tools)
140
-
141
- guide_text = result.text.to_s.strip
142
- # Strip markdown code fences if the LLM wrapped the response
143
- guide_text = guide_text.sub(/\A```(?:markdown)?\s*\n?/, '').sub(/\n?```\s*\z/, '')
144
- # Strip LLM preamble/thinking before the actual guide content
145
- guide_text = guide_text.sub(/\A.*?(?=^#\s)/m, '') if guide_text =~ /^#\s/m
146
-
147
- if guide_text.empty?
148
- $stdout.puts "\e[33m No guide content generated.\e[0m"
149
- return nil
150
- end
151
-
152
- storage.write(ConsoleAgent::GUIDE_KEY, guide_text)
153
-
154
- path = storage.respond_to?(:root_path) ? File.join(storage.root_path, ConsoleAgent::GUIDE_KEY) : ConsoleAgent::GUIDE_KEY
155
- $stdout.puts "\e[32m Guide saved to #{path} (#{guide_text.length} chars)\e[0m"
156
- display_usage(result)
157
- nil
158
- rescue Interrupt
159
- $stdout.puts "\n\e[33m Interrupted.\e[0m"
160
- nil
161
- rescue Providers::ProviderError => e
162
- $stderr.puts "\e[31mConsoleAgent Error: #{e.message}\e[0m"
163
- nil
164
- rescue => e
165
- $stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
166
- nil
167
- ensure
168
- ConsoleAgent.configuration.timeout = original_timeout if original_timeout
21
+ @engine.init_guide
169
22
  end
170
23
 
171
24
  def interactive
172
- init_interactive_state
173
- interactive_loop
25
+ @channel.interactive_loop(@engine)
174
26
  end
175
27
 
176
28
  def resume(session)
177
- init_interactive_state
178
-
179
- # Restore state from the previous session
180
- @history = JSON.parse(session.conversation, symbolize_names: true)
181
- @interactive_session_id = session.id
182
- @interactive_query = session.query
183
- @interactive_session_name = session.name
184
- @total_input_tokens = session.input_tokens || 0
185
- @total_output_tokens = session.output_tokens || 0
186
-
187
- # Seed the capture buffer with previous output so it's preserved on save
188
- @interactive_console_capture.write(session.console_output.to_s)
189
-
190
- # Replay to the user via the real stdout (bypass TeeIO to avoid double-capture)
191
- if session.console_output && !session.console_output.strip.empty?
192
- @interactive_old_stdout.puts "\e[2m--- Replaying previous session output ---\e[0m"
193
- @interactive_old_stdout.puts session.console_output
194
- @interactive_old_stdout.puts "\e[2m--- End of previous output ---\e[0m"
195
- @interactive_old_stdout.puts
196
- end
197
-
198
- interactive_loop
29
+ @channel.resume_interactive(@engine, session)
199
30
  end
200
31
 
201
- private
202
-
203
- def init_interactive_state
204
- @interactive_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
205
- @interactive_console_capture = StringIO.new
206
- @interactive_old_stdout = $stdout
207
- $stdout = TeeIO.new(@interactive_old_stdout, @interactive_console_capture)
208
- @executor.on_prompt = -> { log_interactive_turn }
209
-
210
- @history = []
211
- @total_input_tokens = 0
212
- @total_output_tokens = 0
213
- @token_usage = Hash.new { |h, k| h[k] = { input: 0, output: 0 } }
214
- @interactive_query = nil
215
- @interactive_session_id = nil
216
- @interactive_session_name = nil
217
- @last_interactive_code = nil
218
- @last_interactive_output = nil
219
- @last_interactive_result = nil
220
- @last_interactive_executed = false
221
- @compact_warned = false
222
- end
223
-
224
- def interactive_loop
225
- auto = ConsoleAgent.configuration.auto_execute
226
- name_display = @interactive_session_name ? " (#{@interactive_session_name})" : ""
227
- # Write banner to real stdout (bypass TeeIO) so it doesn't accumulate on resume
228
- @interactive_old_stdout.puts "\e[36mConsoleAgent interactive mode#{name_display}. Type 'exit' or 'quit' to leave.\e[0m"
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"
230
-
231
- # Bind Shift-Tab to insert /auto command and submit
232
- if Readline.respond_to?(:parse_and_bind)
233
- Readline.parse_and_bind('"\e[Z": "\C-a\C-k/auto\C-m"')
234
- end
235
-
236
- loop do
237
- input = Readline.readline("\001\e[33m\002ai> \001\e[0m\002", false)
238
- break if input.nil? # Ctrl-D
239
-
240
- input = input.strip
241
- break if input.downcase == 'exit' || input.downcase == 'quit'
242
- next if input.empty?
243
-
244
- if input == '?' || input == '/'
245
- display_help
246
- next
247
- end
248
-
249
- if input == '/auto'
250
- ConsoleAgent.configuration.auto_execute = !ConsoleAgent.configuration.auto_execute
251
- mode = ConsoleAgent.configuration.auto_execute ? 'ON' : 'OFF'
252
- @interactive_old_stdout.puts "\e[36m Auto-execute: #{mode}\e[0m"
253
- next
254
- end
255
-
256
- if input == '/usage'
257
- display_session_summary
258
- next
259
- end
260
-
261
- if input == '/debug'
262
- ConsoleAgent.configuration.debug = !ConsoleAgent.configuration.debug
263
- mode = ConsoleAgent.configuration.debug ? 'ON' : 'OFF'
264
- @interactive_old_stdout.puts "\e[36m Debug: #{mode}\e[0m"
265
- next
266
- end
267
-
268
- if input == '/compact'
269
- compact_history
270
- next
271
- end
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
-
293
- if input.start_with?('/name')
294
- name = input.sub('/name', '').strip
295
- if name.empty?
296
- if @interactive_session_name
297
- @interactive_old_stdout.puts "\e[36m Session name: #{@interactive_session_name}\e[0m"
298
- else
299
- @interactive_old_stdout.puts "\e[33m Usage: /name <label> (e.g. /name salesforce_user_123)\e[0m"
300
- end
301
- else
302
- @interactive_session_name = name
303
- if @interactive_session_id
304
- require 'console_agent/session_logger'
305
- SessionLogger.update(@interactive_session_id, name: name)
306
- end
307
- @interactive_old_stdout.puts "\e[36m Session named: #{name}\e[0m"
308
- end
309
- next
310
- end
311
-
312
- # Direct code execution with ">" prefix — skip LLM entirely
313
- if input.start_with?('>') && !input.start_with?('>=')
314
- raw_code = input.sub(/\A>\s?/, '')
315
- Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
316
- @interactive_console_capture.write("ai> #{input}\n")
317
-
318
- exec_result = @executor.execute(raw_code)
319
-
320
- output_parts = []
321
- output_parts << "Output:\n#{@executor.last_output.strip}" if @executor.last_output && !@executor.last_output.strip.empty?
322
- output_parts << "Return value: #{exec_result.inspect}" if exec_result
323
-
324
- result_str = output_parts.join("\n\n")
325
- result_str = result_str[0..1000] + '...' if result_str.length > 1000
326
-
327
- context_msg = "User directly executed code: `#{raw_code}`"
328
- context_msg += "\n#{result_str}" unless output_parts.empty?
329
- @history << { role: :user, content: context_msg }
330
-
331
- @interactive_query ||= input
332
- @last_interactive_code = raw_code
333
- @last_interactive_output = @executor.last_output
334
- @last_interactive_result = exec_result ? exec_result.inspect : nil
335
- @last_interactive_executed = true
336
-
337
- log_interactive_turn
338
- next
339
- end
340
-
341
- # Add to Readline history (avoid consecutive duplicates)
342
- Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
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
-
349
- @interactive_query ||= input
350
- @history << { role: :user, content: input }
351
-
352
- # Log the user's prompt line to the console capture (Readline doesn't go through $stdout)
353
- @interactive_console_capture.write("ai> #{input}\n")
354
-
355
- # Save immediately so the session is visible in the admin UI while the AI thinks
356
- log_interactive_turn
357
-
358
- status = send_and_execute
359
- if status == :interrupted
360
- @history.pop # Remove the user message that never got a response
361
- log_interactive_turn
362
- next
363
- end
364
-
365
- # Auto-retry once when execution fails — send error back to LLM for a fix
366
- if status == :error
367
- $stdout.puts "\e[2m Attempting to fix...\e[0m"
368
- log_interactive_turn
369
- send_and_execute
370
- end
371
-
372
- # Update with the AI response, tokens, and any execution results
373
- log_interactive_turn
374
-
375
- warn_if_history_large
376
- end
377
-
378
- $stdout = @interactive_old_stdout
379
- @executor.on_prompt = nil
380
- finish_interactive_session
381
- display_exit_info
382
- rescue Interrupt
383
- # Ctrl-C during Readline input — exit cleanly
384
- $stdout = @interactive_old_stdout if @interactive_old_stdout
385
- @executor.on_prompt = nil
386
- $stdout.puts
387
- finish_interactive_session
388
- display_exit_info
389
- rescue => e
390
- $stdout = @interactive_old_stdout if @interactive_old_stdout
391
- @executor.on_prompt = nil
392
- $stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
393
- end
394
-
395
- # Sends conversation to LLM, displays response, executes code if present.
396
- # Returns :success, :error, :cancelled, :no_code, or :interrupted.
397
- def send_and_execute
398
- begin
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
407
- rescue Interrupt
408
- $stdout.puts "\n\e[33m Aborted.\e[0m"
409
- return :interrupted
410
- end
411
-
412
- track_usage(result)
413
- code = @executor.display_response(result.text)
414
- display_usage(result, show_session: true)
415
-
416
- # Save after response is displayed so viewer shows progress before Execute prompt
417
- log_interactive_turn
418
-
419
- # Add tool call/result messages so the LLM remembers what it learned
420
- @history.concat(tool_messages) if tool_messages && !tool_messages.empty?
421
- @history << { role: :assistant, content: result.text }
422
-
423
- return :no_code unless code && !code.strip.empty?
424
-
425
- exec_result = if ConsoleAgent.configuration.auto_execute
426
- @executor.execute(code)
427
- else
428
- @executor.confirm_and_execute(code)
429
- end
430
-
431
- unless @executor.last_cancelled?
432
- @last_interactive_code = code
433
- @last_interactive_output = @executor.last_output
434
- @last_interactive_result = exec_result ? exec_result.inspect : nil
435
- @last_interactive_executed = true
436
- end
437
-
438
- if @executor.last_cancelled?
439
- @history << { role: :user, content: "User declined to execute the code." }
440
- :cancelled
441
- elsif @executor.last_error
442
- error_msg = "Code execution failed with error: #{@executor.last_error}"
443
- error_msg = error_msg[0..1000] + '...' if error_msg.length > 1000
444
- @history << { role: :user, content: error_msg }
445
- :error
446
- else
447
- output_parts = []
448
-
449
- # Capture printed output (puts, print, etc.)
450
- if @executor.last_output && !@executor.last_output.strip.empty?
451
- output_parts << "Output:\n#{@executor.last_output.strip}"
452
- end
453
-
454
- # Capture return value
455
- if exec_result
456
- output_parts << "Return value: #{exec_result.inspect}"
457
- end
458
-
459
- unless output_parts.empty?
460
- result_str = output_parts.join("\n\n")
461
- result_str = result_str[0..1000] + '...' if result_str.length > 1000
462
- @history << { role: :user, content: "Code was executed. #{result_str}" }
463
- end
464
-
465
- :success
466
- end
467
- end
468
-
469
- def provider
470
- @provider ||= Providers.build
471
- end
472
-
473
- def context_builder
474
- @context_builder ||= ContextBuilder.new
475
- end
476
-
477
- def context
478
- base = @context_base ||= context_builder.build
479
- vars = binding_variable_summary
480
- vars ? "#{base}\n\n#{vars}" : base
481
- end
482
-
483
- # Summarize local and instance variables from the user's console session
484
- # so the LLM knows what's available to reference in generated code.
485
- def binding_variable_summary
486
- parts = []
487
-
488
- locals = @binding_context.local_variables.reject { |v| v.to_s.start_with?('_') }
489
- locals.first(20).each do |var|
490
- val = @binding_context.local_variable_get(var) rescue nil
491
- parts << "#{var} (#{val.class})"
492
- end
493
-
494
- ivars = (@binding_context.eval("instance_variables") rescue [])
495
- ivars.reject { |v| v.to_s =~ /\A@_/ }.first(20).each do |var|
496
- val = @binding_context.eval(var.to_s) rescue nil
497
- parts << "#{var} (#{val.class})"
498
- end
499
-
500
- return nil if parts.empty?
501
- "The user's console session has these variables available: #{parts.join(', ')}. You can reference them directly in code."
502
- rescue
503
- nil
504
- end
505
-
506
- def init_system_prompt(existing_guide)
507
- env = context_builder.environment_context
508
-
509
- prompt = <<~PROMPT
510
- You are a Rails application analyst. Your job is to explore this Rails app using the
511
- available tools and produce a concise markdown guide that will be injected into future
512
- AI assistant sessions.
513
-
514
- #{env}
515
-
516
- EXPLORATION STRATEGY — be efficient to avoid timeouts:
517
- 1. Start with list_models to see all models and their associations
518
- 2. Pick the 5-8 CORE models and call describe_model on those only
519
- 3. Call describe_table on only 3-5 key tables (skip tables whose models already told you enough)
520
- 4. Use search_code sparingly — only for specific patterns you suspect (sharding, STI, concerns)
521
- 5. Use read_file only when you need to understand a specific pattern (read small sections, not whole files)
522
- 6. Do NOT exhaustively describe every table or model — focus on what's important
523
-
524
- IMPORTANT: Keep your total tool calls under 20. Prioritize breadth over depth.
525
-
526
- Produce a markdown document with these sections:
527
- - **Application Overview**: What the app does, key domain concepts
528
- - **Key Models & Relationships**: Core models and how they relate
529
- - **Data Architecture**: Important tables, notable columns, any partitioning/sharding
530
- - **Important Patterns**: Custom concerns, service objects, key abstractions
531
- - **Common Maintenance Tasks**: Typical console operations for this app
532
- - **Gotchas**: Non-obvious behaviors, tricky associations, known quirks
533
-
534
- Keep it concise — aim for 1-2 pages. Focus on what a console user needs to know.
535
- Do NOT wrap the output in markdown code fences.
536
- PROMPT
537
-
538
- if existing_guide
539
- prompt += <<~UPDATE
540
-
541
- Here is the existing guide. Update and merge with any new findings:
542
-
543
- #{existing_guide}
544
- UPDATE
545
- end
546
-
547
- prompt.strip
548
- end
549
-
550
- def send_query(query, conversation: nil)
551
- ConsoleAgent.configuration.validate!
552
-
553
- messages = if conversation
554
- conversation.dup
555
- else
556
- [{ role: :user, content: query }]
557
- end
558
-
559
- send_query_with_tools(messages)
560
- end
561
-
562
- def send_query_with_tools(messages, system_prompt: nil, tools_override: nil)
563
- require 'console_agent/tools/registry'
564
- tools = tools_override || Tools::Registry.new(executor: @executor)
565
- active_system_prompt = system_prompt || context
566
- max_rounds = ConsoleAgent.configuration.max_tool_rounds
567
- total_input = 0
568
- total_output = 0
569
- result = nil
570
- new_messages = [] # Track messages added during tool use
571
- last_thinking = nil
572
- last_tool_names = []
573
-
574
- exhausted = false
575
-
576
- max_rounds.times do |round|
577
- if round == 0
578
- $stdout.puts "\e[2m Thinking...\e[0m"
579
- else
580
- # Show buffered thinking text before the "Calling LLM" line
581
- if last_thinking
582
- last_thinking.split("\n").each do |line|
583
- $stdout.puts "\e[2m #{line}\e[0m"
584
- end
585
- end
586
- $stdout.puts "\e[2m #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}\e[0m"
587
- end
588
-
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
595
- end
596
- total_input += result.input_tokens || 0
597
- total_output += result.output_tokens || 0
598
-
599
- break unless result.tool_use?
600
-
601
- # Buffer thinking text for display before next LLM call
602
- last_thinking = (result.text && !result.text.strip.empty?) ? result.text.strip : nil
603
-
604
- # Add assistant message with tool calls to conversation
605
- assistant_msg = provider.format_assistant_message(result)
606
- messages << assistant_msg
607
- new_messages << assistant_msg
608
-
609
- # Execute each tool and show progress
610
- last_tool_names = result.tool_calls.map { |tc| tc[:name] }
611
- result.tool_calls.each do |tc|
612
- # ask_user and execute_plan handle their own display
613
- if tc[:name] == 'ask_user' || tc[:name] == 'execute_plan'
614
- tool_result = tools.execute(tc[:name], tc[:arguments])
615
- else
616
- args_display = format_tool_args(tc[:name], tc[:arguments])
617
- $stdout.puts "\e[33m -> #{tc[:name]}#{args_display}\e[0m"
618
-
619
- tool_result = tools.execute(tc[:name], tc[:arguments])
620
-
621
- preview = compact_tool_result(tc[:name], tool_result)
622
- cached_tag = tools.last_cached? ? " (cached)" : ""
623
- $stdout.puts "\e[2m #{preview}#{cached_tag}\e[0m"
624
- end
625
-
626
- if ConsoleAgent.configuration.debug
627
- $stderr.puts "\e[35m[debug tool result] #{tool_result}\e[0m"
628
- end
629
-
630
- tool_msg = provider.format_tool_result(tc[:id], tool_result)
631
- messages << tool_msg
632
- new_messages << tool_msg
633
- end
634
-
635
- exhausted = true if round == max_rounds - 1
636
- end
637
-
638
- # If we hit the tool round limit, force a final response without tools
639
- if exhausted
640
- $stdout.puts "\e[33m Hit tool round limit (#{max_rounds}). Forcing final answer. Increase with: ConsoleAgent.configure { |c| c.max_tool_rounds = 200 }\e[0m"
641
- 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." }
642
- result = provider.chat(messages, system_prompt: active_system_prompt)
643
- total_input += result.input_tokens || 0
644
- total_output += result.output_tokens || 0
645
- end
646
-
647
- final_result = Providers::ChatResult.new(
648
- text: result ? result.text : '',
649
- input_tokens: total_input,
650
- output_tokens: total_output,
651
- stop_reason: result ? result.stop_reason : :end_turn
652
- )
653
- [final_result, new_messages]
654
- end
655
-
656
- # Monitors stdin for Escape (or Ctrl+C, since raw mode disables signals)
657
- # and raises Interrupt in the main thread when detected.
658
- def with_escape_monitoring
659
- require 'io/console'
660
- return yield unless $stdin.respond_to?(:raw)
661
-
662
- monitor = Thread.new do
663
- Thread.current.report_on_exception = false
664
- $stdin.raw do |io|
665
- loop do
666
- break if Thread.current[:stop]
667
- ready = IO.select([io], nil, nil, 0.2)
668
- next unless ready
669
-
670
- char = io.read_nonblock(1) rescue nil
671
- next unless char
672
-
673
- if char == "\x03" # Ctrl+C (raw mode eats the signal)
674
- Thread.main.raise(Interrupt)
675
- break
676
- elsif char == "\e"
677
- # Distinguish standalone Escape from escape sequences (arrow keys, etc.)
678
- seq = IO.select([io], nil, nil, 0.05)
679
- if seq
680
- io.read_nonblock(10) rescue nil # consume the sequence
681
- else
682
- Thread.main.raise(Interrupt)
683
- break
684
- end
685
- end
686
- end
687
- end
688
- rescue IOError, Errno::EIO, Errno::ENODEV, Errno::ENOTTY
689
- # stdin is not a TTY (e.g. in tests or piped input) — silently skip
690
- end
691
-
692
- begin
693
- yield
694
- ensure
695
- monitor[:stop] = true
696
- monitor.join(1) rescue nil
697
- end
698
- end
699
-
700
-
701
- def llm_status(round, messages, tokens_so_far, last_thinking = nil, last_tool_names = [])
702
- status = "Calling LLM (round #{round + 1}, #{messages.length} msgs"
703
- status += ", ~#{format_tokens(tokens_so_far)} ctx" if tokens_so_far > 0
704
- status += ")"
705
- if !last_thinking && last_tool_names.any?
706
- # Summarize tools when there's no thinking text
707
- counts = last_tool_names.tally
708
- summary = counts.map { |name, n| n > 1 ? "#{name} x#{n}" : name }.join(", ")
709
- status += " after #{summary}"
710
- end
711
- status += "..."
712
- status
713
- end
714
-
715
- def format_tokens(count)
716
- if count >= 1_000_000
717
- "#{(count / 1_000_000.0).round(1)}M"
718
- elsif count >= 1_000
719
- "#{(count / 1_000.0).round(1)}K"
720
- else
721
- count.to_s
722
- end
723
- end
724
-
725
- def format_tool_args(name, args)
726
- return '' if args.nil? || args.empty?
727
-
32
+ # Expose engine internals for specs that inspect state
33
+ def instance_variable_get(name)
728
34
  case name
729
- when 'describe_table'
730
- "(\"#{args['table_name']}\")"
731
- when 'describe_model'
732
- "(\"#{args['model_name']}\")"
733
- when 'read_file'
734
- "(\"#{args['path']}\")"
735
- when 'search_code'
736
- dir = args['directory'] ? ", dir: \"#{args['directory']}\"" : ''
737
- "(\"#{args['query']}\"#{dir})"
738
- when 'list_files'
739
- args['directory'] ? "(\"#{args['directory']}\")" : ''
740
- when 'save_memory'
741
- "(\"#{args['name']}\")"
742
- when 'delete_memory'
743
- "(\"#{args['name']}\")"
744
- when 'recall_memories'
745
- args['query'] ? "(\"#{args['query']}\")" : ''
746
- when 'execute_plan'
747
- steps = args['steps']
748
- steps ? "(#{steps.length} steps)" : ''
35
+ when :@history
36
+ @engine.history
37
+ when :@executor
38
+ @engine.instance_variable_get(:@executor)
749
39
  else
750
- ''
40
+ super
751
41
  end
752
42
  end
753
43
 
754
- def compact_tool_result(name, result)
755
- return '(empty)' if result.nil? || result.strip.empty?
756
-
44
+ # Allow specs to set internal state
45
+ def instance_variable_set(name, value)
757
46
  case name
758
- when 'list_tables'
759
- tables = result.split(', ')
760
- if tables.length > 8
761
- "#{tables.length} tables: #{tables.first(8).join(', ')}..."
762
- else
763
- "#{tables.length} tables: #{result}"
764
- end
765
- when 'list_models'
766
- lines = result.split("\n")
767
- if lines.length > 6
768
- "#{lines.length} models: #{lines.first(6).map { |l| l.split(' ').first }.join(', ')}..."
769
- else
770
- "#{lines.length} models"
771
- end
772
- when 'describe_table'
773
- col_count = result.scan(/^\s{2}\S/).length
774
- "#{col_count} columns"
775
- when 'describe_model'
776
- parts = []
777
- assoc_count = result.scan(/^\s{2}(has_many|has_one|belongs_to|has_and_belongs_to_many)/).length
778
- val_count = result.scan(/^\s{2}(presence|uniqueness|format|length|numericality|inclusion|exclusion|confirmation|acceptance)/).length
779
- parts << "#{assoc_count} associations" if assoc_count > 0
780
- parts << "#{val_count} validations" if val_count > 0
781
- parts.empty? ? truncate(result, 80) : parts.join(', ')
782
- when 'list_files'
783
- lines = result.split("\n")
784
- "#{lines.length} files"
785
- when 'read_file'
786
- if result =~ /^Lines (\d+)-(\d+) of (\d+):/
787
- "lines #{$1}-#{$2} of #{$3}"
788
- else
789
- lines = result.split("\n")
790
- "#{lines.length} lines"
791
- end
792
- when 'search_code'
793
- if result.start_with?('Found')
794
- result.split("\n").first
795
- elsif result.start_with?('No matches')
796
- result
797
- else
798
- truncate(result, 80)
799
- end
800
- when 'save_memory'
801
- (result.start_with?('Memory saved') || result.start_with?('Memory updated')) ? result : truncate(result, 80)
802
- when 'delete_memory'
803
- result.start_with?('Memory deleted') ? result : truncate(result, 80)
804
- when 'recall_memories'
805
- chunks = result.split("\n\n")
806
- chunks.length > 1 ? "#{chunks.length} memories found" : truncate(result, 80)
807
- when 'execute_plan'
808
- steps_done = result.scan(/^Step \d+/).length
809
- steps_done > 0 ? "#{steps_done} steps executed" : truncate(result, 80)
810
- else
811
- truncate(result, 80)
812
- end
813
- end
814
-
815
- def truncate(str, max)
816
- str.length > max ? str[0..max] + '...' : str
817
- end
818
-
819
- def track_usage(result)
820
- @total_input_tokens += result.input_tokens || 0
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
826
- end
827
-
828
- def display_usage(result, show_session: false)
829
- input = result.input_tokens
830
- output = result.output_tokens
831
- return unless input || output
832
-
833
- parts = []
834
- parts << "in: #{input}" if input
835
- parts << "out: #{output}" if output
836
- parts << "total: #{result.total_tokens}"
837
-
838
- line = "\e[2m[tokens #{parts.join(' | ')}]\e[0m"
839
-
840
- if show_session && (@total_input_tokens + @total_output_tokens) > result.total_tokens
841
- line += "\e[2m [session: in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
842
- end
843
-
844
- $stdout.puts line
845
- end
846
-
847
- def with_console_capture(capture_io)
848
- old_stdout = $stdout
849
- $stdout = TeeIO.new(old_stdout, capture_io)
850
- yield
851
- ensure
852
- $stdout = old_stdout
853
- end
854
-
855
- def log_interactive_turn
856
- require 'console_agent/session_logger'
857
- session_attrs = {
858
- conversation: @history,
859
- input_tokens: @total_input_tokens,
860
- output_tokens: @total_output_tokens,
861
- code_executed: @last_interactive_code,
862
- code_output: @last_interactive_output,
863
- code_result: @last_interactive_result,
864
- executed: @last_interactive_executed,
865
- console_output: @interactive_console_capture&.string
866
- }
867
-
868
- if @interactive_session_id
869
- SessionLogger.update(@interactive_session_id, session_attrs)
870
- else
871
- @interactive_session_id = SessionLogger.log(
872
- session_attrs.merge(
873
- query: @interactive_query || '(interactive session)',
874
- mode: 'interactive',
875
- name: @interactive_session_name
876
- )
877
- )
878
- end
879
- end
880
-
881
- def finish_interactive_session
882
- require 'console_agent/session_logger'
883
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @interactive_start) * 1000).round
884
- if @interactive_session_id
885
- SessionLogger.update(@interactive_session_id,
886
- conversation: @history,
887
- input_tokens: @total_input_tokens,
888
- output_tokens: @total_output_tokens,
889
- code_executed: @last_interactive_code,
890
- code_output: @last_interactive_output,
891
- code_result: @last_interactive_result,
892
- executed: @last_interactive_executed,
893
- console_output: @interactive_console_capture&.string,
894
- duration_ms: duration_ms
895
- )
896
- elsif @interactive_query
897
- # Session was never created (e.g., only one turn that failed to log)
898
- log_session(
899
- query: @interactive_query,
900
- conversation: @history,
901
- mode: 'interactive',
902
- code_executed: @last_interactive_code,
903
- code_output: @last_interactive_output,
904
- code_result: @last_interactive_result,
905
- executed: @last_interactive_executed,
906
- console_output: @interactive_console_capture&.string,
907
- start_time: @interactive_start
908
- )
909
- end
910
- end
911
-
912
- def log_session(attrs)
913
- require 'console_agent/session_logger'
914
- start_time = attrs.delete(:start_time)
915
- duration_ms = if start_time
916
- ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
917
- end
918
- SessionLogger.log(
919
- attrs.merge(
920
- input_tokens: @total_input_tokens,
921
- output_tokens: @total_output_tokens,
922
- duration_ms: duration_ms
923
- )
924
- )
925
- end
926
-
927
- def display_session_summary
928
- return if @total_input_tokens == 0 && @total_output_tokens == 0
929
-
930
- $stdout.puts "\e[2m[session totals — in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
931
- end
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"
47
+ when :@history
48
+ @engine.instance_variable_set(:@history, value)
966
49
  else
967
- config.model = thinking
968
- @provider = nil
969
- $stdout.puts "\e[36m Switched to thinking model: #{thinking}\e[0m"
50
+ super
970
51
  end
971
52
  end
972
53
 
973
- def on_thinking_model?
974
- config = ConsoleAgent.configuration
975
- config.resolved_model == config.resolved_thinking_model
976
- end
977
-
978
- def warn_if_history_large
979
- chars = @history.sum { |m| m[:content].to_s.length }
980
-
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
985
- end
986
-
987
- def compact_history
988
- if @history.length < 6
989
- $stdout.puts "\e[33m History too short to compact (#{@history.length} messages). Need at least 6.\e[0m"
990
- return
991
- end
992
-
993
- before_chars = @history.sum { |m| m[:content].to_s.length }
994
- before_count = @history.length
995
-
996
- # Extract successfully executed code before summarizing
997
- executed_code = extract_executed_code(@history)
998
-
999
- $stdout.puts "\e[2m Compacting #{before_count} messages (~#{format_tokens(before_chars)} chars)...\e[0m"
1000
-
1001
- system_prompt = <<~PROMPT
1002
- You are a conversation summarizer. The user will provide a conversation history from a Rails console AI assistant session.
1003
-
1004
- Produce a concise summary that captures:
1005
- - What the user has been working on and their goals
1006
- - Key findings and data discovered (include specific values, IDs, record counts)
1007
- - Current state: what worked, what failed, where things stand
1008
- - Important variable names, model names, or table names referenced
1009
-
1010
- Do NOT include code that was executed — that will be preserved separately.
1011
- Be concise but preserve all information that would be needed to continue the conversation naturally.
1012
- Do NOT include any preamble — just output the summary directly.
1013
- PROMPT
1014
-
1015
- history_text = @history.map { |m| "#{m[:role]}: #{m[:content]}" }.join("\n\n")
1016
- messages = [{ role: :user, content: "Summarize this conversation history:\n\n#{history_text}" }]
1017
-
1018
- begin
1019
- result = provider.chat(messages, system_prompt: system_prompt)
1020
- track_usage(result)
1021
-
1022
- summary = result.text.to_s.strip
1023
- if summary.empty?
1024
- $stdout.puts "\e[33m Compaction failed: empty summary returned.\e[0m"
1025
- return
1026
- end
1027
-
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 }]
1034
- @compact_warned = false
1035
-
1036
- after_chars = @history.first[:content].length
1037
- $stdout.puts "\e[36m Compacted: #{before_count} messages -> 1 summary (~#{format_tokens(before_chars)} -> ~#{format_tokens(after_chars)} chars)\e[0m"
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
1042
- display_usage(result)
1043
- rescue => e
1044
- $stdout.puts "\e[31m Compaction failed: #{e.message}\e[0m"
1045
- end
1046
- end
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
54
+ private
1135
55
 
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"
56
+ # Expose send methods for spec compatibility
57
+ def send_query(query, conversation: nil)
58
+ @engine.send(:send_query, query, conversation: conversation)
1150
59
  end
1151
60
 
1152
- def display_exit_info
1153
- display_session_summary
1154
- if @interactive_session_id
1155
- $stdout.puts "\e[36mSession ##{@interactive_session_id} saved.\e[0m"
1156
- if @interactive_session_name
1157
- $stdout.puts "\e[2m Resume with: ai_resume \"#{@interactive_session_name}\"\e[0m"
1158
- else
1159
- $stdout.puts "\e[2m Name it: ai_name #{@interactive_session_id}, \"descriptive_name\"\e[0m"
1160
- $stdout.puts "\e[2m Resume it: ai_resume #{@interactive_session_id}\e[0m"
1161
- end
1162
- end
1163
- $stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
61
+ def trim_old_outputs(messages)
62
+ @engine.send(:trim_old_outputs, messages)
1164
63
  end
1165
64
  end
1166
65
  end