console_agent 0.10.0 → 0.12.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,1330 +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
199
- end
200
-
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.start_with?('/expand')
289
- expand_id = input.sub('/expand', '').strip.to_i
290
- full_output = @executor.expand_output(expand_id)
291
- if full_output
292
- @interactive_old_stdout.puts full_output
293
- else
294
- @interactive_old_stdout.puts "\e[33mNo omitted output with id #{expand_id}\e[0m"
295
- end
296
- next
297
- end
298
-
299
- if input == '/think'
300
- upgrade_to_thinking_model
301
- next
302
- end
303
-
304
- if input.start_with?('/name')
305
- name = input.sub('/name', '').strip
306
- if name.empty?
307
- if @interactive_session_name
308
- @interactive_old_stdout.puts "\e[36m Session name: #{@interactive_session_name}\e[0m"
309
- else
310
- @interactive_old_stdout.puts "\e[33m Usage: /name <label> (e.g. /name salesforce_user_123)\e[0m"
311
- end
312
- else
313
- @interactive_session_name = name
314
- if @interactive_session_id
315
- require 'console_agent/session_logger'
316
- SessionLogger.update(@interactive_session_id, name: name)
317
- end
318
- @interactive_old_stdout.puts "\e[36m Session named: #{name}\e[0m"
319
- end
320
- next
321
- end
322
-
323
- # Direct code execution with ">" prefix — skip LLM entirely
324
- if input.start_with?('>') && !input.start_with?('>=')
325
- raw_code = input.sub(/\A>\s?/, '')
326
- Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
327
- @interactive_console_capture.write("ai> #{input}\n")
328
-
329
- exec_result = @executor.execute(raw_code)
330
-
331
- output_parts = []
332
- output_parts << "Output:\n#{@executor.last_output.strip}" if @executor.last_output && !@executor.last_output.strip.empty?
333
- output_parts << "Return value: #{exec_result.inspect}" if exec_result
334
-
335
- result_str = output_parts.join("\n\n")
336
- result_str = result_str[0..1000] + '...' if result_str.length > 1000
337
-
338
- context_msg = "User directly executed code: `#{raw_code}`"
339
- context_msg += "\n#{result_str}" unless output_parts.empty?
340
- output_id = output_parts.empty? ? nil : @executor.store_output(result_str)
341
- @history << { role: :user, content: context_msg, output_id: output_id }
342
-
343
- @interactive_query ||= input
344
- @last_interactive_code = raw_code
345
- @last_interactive_output = @executor.last_output
346
- @last_interactive_result = exec_result ? exec_result.inspect : nil
347
- @last_interactive_executed = true
348
-
349
- log_interactive_turn
350
- next
351
- end
352
-
353
- # Add to Readline history (avoid consecutive duplicates)
354
- Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
355
-
356
- # Auto-upgrade to thinking model on "think harder" phrases
357
- if input =~ /think\s*harder/i
358
- upgrade_to_thinking_model
359
- end
360
-
361
- @interactive_query ||= input
362
- @history << { role: :user, content: input }
363
-
364
- # Log the user's prompt line to the console capture (Readline doesn't go through $stdout)
365
- @interactive_console_capture.write("ai> #{input}\n")
366
-
367
- # Save immediately so the session is visible in the admin UI while the AI thinks
368
- log_interactive_turn
369
-
370
- status = send_and_execute
371
- if status == :interrupted
372
- @history.pop # Remove the user message that never got a response
373
- log_interactive_turn
374
- next
375
- end
376
-
377
- # Auto-retry once when execution fails — send error back to LLM for a fix
378
- if status == :error
379
- $stdout.puts "\e[2m Attempting to fix...\e[0m"
380
- log_interactive_turn
381
- send_and_execute
382
- end
383
-
384
- # Update with the AI response, tokens, and any execution results
385
- log_interactive_turn
386
-
387
- warn_if_history_large
388
- end
389
-
390
- $stdout = @interactive_old_stdout
391
- @executor.on_prompt = nil
392
- finish_interactive_session
393
- display_exit_info
394
- rescue Interrupt
395
- # Ctrl-C during Readline input — exit cleanly
396
- $stdout = @interactive_old_stdout if @interactive_old_stdout
397
- @executor.on_prompt = nil
398
- $stdout.puts
399
- finish_interactive_session
400
- display_exit_info
401
- rescue => e
402
- $stdout = @interactive_old_stdout if @interactive_old_stdout
403
- @executor.on_prompt = nil
404
- $stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
405
- end
406
-
407
- # Sends conversation to LLM, displays response, executes code if present.
408
- # Returns :success, :error, :cancelled, :no_code, or :interrupted.
409
- def send_and_execute
410
- begin
411
- result, tool_messages = send_query(nil, conversation: @history)
412
- rescue Providers::ProviderError => e
413
- if e.message.include?("prompt is too long") && @history.length >= 6
414
- $stdout.puts "\e[33m Context limit reached. Run /compact to reduce context size, then try again.\e[0m"
415
- else
416
- $stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
417
- end
418
- return :error
419
- rescue Interrupt
420
- $stdout.puts "\n\e[33m Aborted.\e[0m"
421
- return :interrupted
422
- end
423
-
424
- track_usage(result)
425
- code = @executor.display_response(result.text)
426
- display_usage(result, show_session: true)
427
-
428
- # Save after response is displayed so viewer shows progress before Execute prompt
429
- log_interactive_turn
430
-
431
- # Add tool call/result messages so the LLM remembers what it learned
432
- @history.concat(tool_messages) if tool_messages && !tool_messages.empty?
433
- @history << { role: :assistant, content: result.text }
434
-
435
- return :no_code unless code && !code.strip.empty?
436
-
437
- exec_result = if ConsoleAgent.configuration.auto_execute
438
- @executor.execute(code)
439
- else
440
- @executor.confirm_and_execute(code)
441
- end
442
-
443
- unless @executor.last_cancelled?
444
- @last_interactive_code = code
445
- @last_interactive_output = @executor.last_output
446
- @last_interactive_result = exec_result ? exec_result.inspect : nil
447
- @last_interactive_executed = true
448
- end
449
-
450
- if @executor.last_cancelled?
451
- @history << { role: :user, content: "User declined to execute the code." }
452
- :cancelled
453
- elsif @executor.last_error
454
- error_msg = "Code execution failed with error: #{@executor.last_error}"
455
- error_msg = error_msg[0..1000] + '...' if error_msg.length > 1000
456
- @history << { role: :user, content: error_msg }
457
- :error
458
- else
459
- output_parts = []
460
-
461
- # Capture printed output (puts, print, etc.)
462
- if @executor.last_output && !@executor.last_output.strip.empty?
463
- output_parts << "Output:\n#{@executor.last_output.strip}"
464
- end
465
-
466
- # Capture return value
467
- if exec_result
468
- output_parts << "Return value: #{exec_result.inspect}"
469
- end
470
-
471
- unless output_parts.empty?
472
- result_str = output_parts.join("\n\n")
473
- result_str = result_str[0..1000] + '...' if result_str.length > 1000
474
- output_id = @executor.store_output(result_str)
475
- @history << { role: :user, content: "Code was executed. #{result_str}", output_id: output_id }
476
- end
477
-
478
- :success
479
- end
480
- end
481
-
482
- def provider
483
- @provider ||= Providers.build
484
- end
485
-
486
- def context_builder
487
- @context_builder ||= ContextBuilder.new
488
- end
489
-
490
- def context
491
- base = @context_base ||= context_builder.build
492
- vars = binding_variable_summary
493
- vars ? "#{base}\n\n#{vars}" : base
494
- end
495
-
496
- # Summarize local and instance variables from the user's console session
497
- # so the LLM knows what's available to reference in generated code.
498
- def binding_variable_summary
499
- parts = []
500
-
501
- locals = @binding_context.local_variables.reject { |v| v.to_s.start_with?('_') }
502
- locals.first(20).each do |var|
503
- val = @binding_context.local_variable_get(var) rescue nil
504
- parts << "#{var} (#{val.class})"
505
- end
506
-
507
- ivars = (@binding_context.eval("instance_variables") rescue [])
508
- ivars.reject { |v| v.to_s =~ /\A@_/ }.first(20).each do |var|
509
- val = @binding_context.eval(var.to_s) rescue nil
510
- parts << "#{var} (#{val.class})"
511
- end
512
-
513
- return nil if parts.empty?
514
- "The user's console session has these variables available: #{parts.join(', ')}. You can reference them directly in code."
515
- rescue
516
- nil
517
- end
518
-
519
- def init_system_prompt(existing_guide)
520
- env = context_builder.environment_context
521
-
522
- prompt = <<~PROMPT
523
- You are a Rails application analyst. Your job is to explore this Rails app using the
524
- available tools and produce a concise markdown guide that will be injected into future
525
- AI assistant sessions.
526
-
527
- #{env}
528
-
529
- EXPLORATION STRATEGY — be efficient to avoid timeouts:
530
- 1. Start with list_models to see all models and their associations
531
- 2. Pick the 5-8 CORE models and call describe_model on those only
532
- 3. Call describe_table on only 3-5 key tables (skip tables whose models already told you enough)
533
- 4. Use search_code sparingly — only for specific patterns you suspect (sharding, STI, concerns)
534
- 5. Use read_file only when you need to understand a specific pattern (read small sections, not whole files)
535
- 6. Do NOT exhaustively describe every table or model — focus on what's important
536
-
537
- IMPORTANT: Keep your total tool calls under 20. Prioritize breadth over depth.
538
-
539
- Produce a markdown document with these sections:
540
- - **Application Overview**: What the app does, key domain concepts
541
- - **Key Models & Relationships**: Core models and how they relate
542
- - **Data Architecture**: Important tables, notable columns, any partitioning/sharding
543
- - **Important Patterns**: Custom concerns, service objects, key abstractions
544
- - **Common Maintenance Tasks**: Typical console operations for this app
545
- - **Gotchas**: Non-obvious behaviors, tricky associations, known quirks
546
-
547
- Keep it concise — aim for 1-2 pages. Focus on what a console user needs to know.
548
- Do NOT wrap the output in markdown code fences.
549
- PROMPT
550
-
551
- if existing_guide
552
- prompt += <<~UPDATE
553
-
554
- Here is the existing guide. Update and merge with any new findings:
555
-
556
- #{existing_guide}
557
- UPDATE
558
- end
559
-
560
- prompt.strip
561
- end
562
-
563
- # Number of most recent execution outputs to keep in full in the conversation.
564
- # Older outputs are replaced with a short reference the LLM can recall via tool.
565
- RECENT_OUTPUTS_TO_KEEP = 2
566
-
567
- def send_query(query, conversation: nil)
568
- ConsoleAgent.configuration.validate!
569
-
570
- messages = if conversation
571
- conversation.dup
572
- else
573
- [{ role: :user, content: query }]
574
- end
575
-
576
- messages = trim_old_outputs(messages) if conversation
577
-
578
- send_query_with_tools(messages)
579
- end
580
-
581
- def send_query_with_tools(messages, system_prompt: nil, tools_override: nil)
582
- require 'console_agent/tools/registry'
583
- tools = tools_override || Tools::Registry.new(executor: @executor)
584
- active_system_prompt = system_prompt || context
585
- max_rounds = ConsoleAgent.configuration.max_tool_rounds
586
- total_input = 0
587
- total_output = 0
588
- result = nil
589
- new_messages = [] # Track messages added during tool use
590
- last_thinking = nil
591
- last_tool_names = []
592
-
593
- exhausted = false
594
-
595
- max_rounds.times do |round|
596
- if round == 0
597
- $stdout.puts "\e[2m Thinking...\e[0m"
598
- else
599
- # Show buffered thinking text before the "Calling LLM" line
600
- if last_thinking
601
- last_thinking.split("\n").each do |line|
602
- $stdout.puts "\e[2m #{line}\e[0m"
603
- end
604
- end
605
- $stdout.puts "\e[2m #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}\e[0m"
606
- end
607
-
608
- if ConsoleAgent.configuration.debug
609
- debug_pre_call(round, messages, active_system_prompt, tools, total_input, total_output)
610
- end
611
-
612
- begin
613
- result = with_escape_monitoring do
614
- provider.chat_with_tools(messages, tools: tools, system_prompt: active_system_prompt)
615
- end
616
- rescue Providers::ProviderError => e
617
- raise
618
- end
619
- total_input += result.input_tokens || 0
620
- total_output += result.output_tokens || 0
621
-
622
- if ConsoleAgent.configuration.debug
623
- debug_post_call(round, result, @total_input_tokens + total_input, @total_output_tokens + total_output)
624
- end
625
-
626
- break unless result.tool_use?
627
-
628
- # Buffer thinking text for display before next LLM call
629
- last_thinking = (result.text && !result.text.strip.empty?) ? result.text.strip : nil
630
-
631
- # Add assistant message with tool calls to conversation
632
- assistant_msg = provider.format_assistant_message(result)
633
- messages << assistant_msg
634
- new_messages << assistant_msg
635
-
636
- # Execute each tool and show progress
637
- last_tool_names = result.tool_calls.map { |tc| tc[:name] }
638
- result.tool_calls.each do |tc|
639
- # ask_user and execute_plan handle their own display
640
- if tc[:name] == 'ask_user' || tc[:name] == 'execute_plan'
641
- tool_result = tools.execute(tc[:name], tc[:arguments])
642
- else
643
- args_display = format_tool_args(tc[:name], tc[:arguments])
644
- $stdout.puts "\e[33m -> #{tc[:name]}#{args_display}\e[0m"
645
-
646
- tool_result = tools.execute(tc[:name], tc[:arguments])
647
-
648
- preview = compact_tool_result(tc[:name], tool_result)
649
- cached_tag = tools.last_cached? ? " (cached)" : ""
650
- $stdout.puts "\e[2m #{preview}#{cached_tag}\e[0m"
651
- end
652
-
653
- if ConsoleAgent.configuration.debug
654
- $stderr.puts "\e[35m[debug] tool result (#{tool_result.to_s.length} chars)\e[0m"
655
- end
656
-
657
- tool_msg = provider.format_tool_result(tc[:id], tool_result)
658
- # Store large tool results so they can be trimmed from older conversation turns
659
- if tool_result.to_s.length > 200
660
- tool_msg[:output_id] = @executor.store_output(tool_result.to_s)
661
- end
662
- messages << tool_msg
663
- new_messages << tool_msg
664
- end
665
-
666
- exhausted = true if round == max_rounds - 1
667
- end
668
-
669
- # If we hit the tool round limit, force a final response without tools
670
- if exhausted
671
- $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"
672
- 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." }
673
- result = provider.chat(messages, system_prompt: active_system_prompt)
674
- total_input += result.input_tokens || 0
675
- total_output += result.output_tokens || 0
676
- end
677
-
678
- final_result = Providers::ChatResult.new(
679
- text: result ? result.text : '',
680
- input_tokens: total_input,
681
- output_tokens: total_output,
682
- stop_reason: result ? result.stop_reason : :end_turn
683
- )
684
- [final_result, new_messages]
685
- end
686
-
687
- # Monitors stdin for Escape (or Ctrl+C, since raw mode disables signals)
688
- # and raises Interrupt in the main thread when detected.
689
- def with_escape_monitoring
690
- require 'io/console'
691
- return yield unless $stdin.respond_to?(:raw)
692
-
693
- monitor = Thread.new do
694
- Thread.current.report_on_exception = false
695
- $stdin.raw do |io|
696
- loop do
697
- break if Thread.current[:stop]
698
- ready = IO.select([io], nil, nil, 0.2)
699
- next unless ready
700
-
701
- char = io.read_nonblock(1) rescue nil
702
- next unless char
703
-
704
- if char == "\x03" # Ctrl+C (raw mode eats the signal)
705
- Thread.main.raise(Interrupt)
706
- break
707
- elsif char == "\e"
708
- # Distinguish standalone Escape from escape sequences (arrow keys, etc.)
709
- seq = IO.select([io], nil, nil, 0.05)
710
- if seq
711
- io.read_nonblock(10) rescue nil # consume the sequence
712
- else
713
- Thread.main.raise(Interrupt)
714
- break
715
- end
716
- end
717
- end
718
- end
719
- rescue IOError, Errno::EIO, Errno::ENODEV, Errno::ENOTTY
720
- # stdin is not a TTY (e.g. in tests or piped input) — silently skip
721
- end
722
-
723
- begin
724
- yield
725
- ensure
726
- monitor[:stop] = true
727
- monitor.join(1) rescue nil
728
- end
729
- end
730
-
731
-
732
- def llm_status(round, messages, tokens_so_far, last_thinking = nil, last_tool_names = [])
733
- status = "Calling LLM (round #{round + 1}, #{messages.length} msgs"
734
- status += ", ~#{format_tokens(tokens_so_far)} ctx" if tokens_so_far > 0
735
- status += ")"
736
- if !last_thinking && last_tool_names.any?
737
- # Summarize tools when there's no thinking text
738
- counts = last_tool_names.tally
739
- summary = counts.map { |name, n| n > 1 ? "#{name} x#{n}" : name }.join(", ")
740
- status += " after #{summary}"
741
- end
742
- status += "..."
743
- status
744
- end
745
-
746
- def debug_pre_call(round, messages, system_prompt, tools, total_input, total_output)
747
- d = "\e[35m"
748
- r = "\e[0m"
749
-
750
- # Count message types
751
- user_msgs = 0
752
- assistant_msgs = 0
753
- tool_result_msgs = 0
754
- tool_use_msgs = 0
755
- output_msgs = 0
756
- omitted_msgs = 0
757
- total_content_chars = system_prompt.to_s.length
758
-
759
- messages.each do |msg|
760
- content_str = msg[:content].is_a?(Array) ? msg[:content].to_s : msg[:content].to_s
761
- total_content_chars += content_str.length
762
-
763
- role = msg[:role].to_s
764
- if role == 'tool'
765
- tool_result_msgs += 1
766
- elsif msg[:content].is_a?(Array)
767
- # Anthropic format — check for tool_result or tool_use blocks
768
- msg[:content].each do |block|
769
- next unless block.is_a?(Hash)
770
- if block['type'] == 'tool_result'
771
- tool_result_msgs += 1
772
- omitted_msgs += 1 if block['content'].to_s.include?('Output omitted')
773
- elsif block['type'] == 'tool_use'
774
- tool_use_msgs += 1
775
- end
776
- end
777
- elsif role == 'user'
778
- user_msgs += 1
779
- if content_str.include?('Code was executed') || content_str.include?('directly executed code')
780
- output_msgs += 1
781
- omitted_msgs += 1 if content_str.include?('Output omitted')
782
- end
783
- elsif role == 'assistant'
784
- assistant_msgs += 1
785
- end
786
- end
787
-
788
- tool_count = tools.respond_to?(:definitions) ? tools.definitions.length : 0
789
-
790
- $stderr.puts "#{d}[debug] ── LLM call ##{round + 1} ──#{r}"
791
- $stderr.puts "#{d}[debug] system prompt: #{format_tokens(system_prompt.to_s.length)} chars#{r}"
792
- $stderr.puts "#{d}[debug] messages: #{messages.length} (#{user_msgs} user, #{assistant_msgs} assistant, #{tool_result_msgs} tool results, #{tool_use_msgs} tool calls)#{r}"
793
- $stderr.puts "#{d}[debug] execution outputs: #{output_msgs} (#{omitted_msgs} omitted)#{r}" if output_msgs > 0 || omitted_msgs > 0
794
- $stderr.puts "#{d}[debug] tools provided: #{tool_count}#{r}"
795
- $stderr.puts "#{d}[debug] est. content size: #{format_tokens(total_content_chars)} chars#{r}"
796
- if total_input > 0 || total_output > 0
797
- $stderr.puts "#{d}[debug] tokens so far: in: #{format_tokens(total_input)} | out: #{format_tokens(total_output)}#{r}"
798
- end
799
- end
800
-
801
- def debug_post_call(round, result, total_input, total_output)
802
- d = "\e[35m"
803
- r = "\e[0m"
804
-
805
- input_t = result.input_tokens || 0
806
- output_t = result.output_tokens || 0
807
- model = ConsoleAgent.configuration.resolved_model
808
- pricing = Configuration::PRICING[model]
809
-
810
- parts = ["in: #{format_tokens(input_t)}", "out: #{format_tokens(output_t)}"]
811
-
812
- if pricing
813
- cost = (input_t * pricing[:input]) + (output_t * pricing[:output])
814
- session_cost = (total_input * pricing[:input]) + (total_output * pricing[:output])
815
- parts << "~$#{'%.4f' % cost}"
816
- $stderr.puts "#{d}[debug] ← response: #{parts.join(' | ')} (session: ~$#{'%.4f' % session_cost})#{r}"
817
- else
818
- $stderr.puts "#{d}[debug] ← response: #{parts.join(' | ')}#{r}"
819
- end
820
-
821
- if result.tool_use?
822
- tool_names = result.tool_calls.map { |tc| tc[:name] }
823
- $stderr.puts "#{d}[debug] tool calls: #{tool_names.join(', ')}#{r}"
824
- else
825
- $stderr.puts "#{d}[debug] stop reason: #{result.stop_reason}#{r}"
826
- end
827
- end
828
-
829
- def format_tokens(count)
830
- if count >= 1_000_000
831
- "#{(count / 1_000_000.0).round(1)}M"
832
- elsif count >= 1_000
833
- "#{(count / 1_000.0).round(1)}K"
834
- else
835
- count.to_s
836
- end
29
+ @channel.resume_interactive(@engine, session)
837
30
  end
838
31
 
839
- def format_tool_args(name, args)
840
- return '' if args.nil? || args.empty?
841
-
32
+ # Expose engine internals for specs that inspect state
33
+ def instance_variable_get(name)
842
34
  case name
843
- when 'describe_table'
844
- "(\"#{args['table_name']}\")"
845
- when 'describe_model'
846
- "(\"#{args['model_name']}\")"
847
- when 'read_file'
848
- "(\"#{args['path']}\")"
849
- when 'search_code'
850
- dir = args['directory'] ? ", dir: \"#{args['directory']}\"" : ''
851
- "(\"#{args['query']}\"#{dir})"
852
- when 'list_files'
853
- args['directory'] ? "(\"#{args['directory']}\")" : ''
854
- when 'save_memory'
855
- "(\"#{args['name']}\")"
856
- when 'delete_memory'
857
- "(\"#{args['name']}\")"
858
- when 'recall_memories'
859
- args['query'] ? "(\"#{args['query']}\")" : ''
860
- when 'execute_plan'
861
- steps = args['steps']
862
- steps ? "(#{steps.length} steps)" : ''
35
+ when :@history
36
+ @engine.history
37
+ when :@executor
38
+ @engine.instance_variable_get(:@executor)
863
39
  else
864
- ''
40
+ super
865
41
  end
866
42
  end
867
43
 
868
- def compact_tool_result(name, result)
869
- return '(empty)' if result.nil? || result.strip.empty?
870
-
44
+ # Allow specs to set internal state
45
+ def instance_variable_set(name, value)
871
46
  case name
872
- when 'list_tables'
873
- tables = result.split(', ')
874
- if tables.length > 8
875
- "#{tables.length} tables: #{tables.first(8).join(', ')}..."
876
- else
877
- "#{tables.length} tables: #{result}"
878
- end
879
- when 'list_models'
880
- lines = result.split("\n")
881
- if lines.length > 6
882
- "#{lines.length} models: #{lines.first(6).map { |l| l.split(' ').first }.join(', ')}..."
883
- else
884
- "#{lines.length} models"
885
- end
886
- when 'describe_table'
887
- col_count = result.scan(/^\s{2}\S/).length
888
- "#{col_count} columns"
889
- when 'describe_model'
890
- parts = []
891
- assoc_count = result.scan(/^\s{2}(has_many|has_one|belongs_to|has_and_belongs_to_many)/).length
892
- val_count = result.scan(/^\s{2}(presence|uniqueness|format|length|numericality|inclusion|exclusion|confirmation|acceptance)/).length
893
- parts << "#{assoc_count} associations" if assoc_count > 0
894
- parts << "#{val_count} validations" if val_count > 0
895
- parts.empty? ? truncate(result, 80) : parts.join(', ')
896
- when 'list_files'
897
- lines = result.split("\n")
898
- "#{lines.length} files"
899
- when 'read_file'
900
- if result =~ /^Lines (\d+)-(\d+) of (\d+):/
901
- "lines #{$1}-#{$2} of #{$3}"
902
- else
903
- lines = result.split("\n")
904
- "#{lines.length} lines"
905
- end
906
- when 'search_code'
907
- if result.start_with?('Found')
908
- result.split("\n").first
909
- elsif result.start_with?('No matches')
910
- result
911
- else
912
- truncate(result, 80)
913
- end
914
- when 'save_memory'
915
- (result.start_with?('Memory saved') || result.start_with?('Memory updated')) ? result : truncate(result, 80)
916
- when 'delete_memory'
917
- result.start_with?('Memory deleted') ? result : truncate(result, 80)
918
- when 'recall_memories'
919
- chunks = result.split("\n\n")
920
- chunks.length > 1 ? "#{chunks.length} memories found" : truncate(result, 80)
921
- when 'execute_plan'
922
- steps_done = result.scan(/^Step \d+/).length
923
- steps_done > 0 ? "#{steps_done} steps executed" : truncate(result, 80)
924
- else
925
- truncate(result, 80)
926
- end
927
- end
928
-
929
- def truncate(str, max)
930
- str.length > max ? str[0..max] + '...' : str
931
- end
932
-
933
- def track_usage(result)
934
- @total_input_tokens += result.input_tokens || 0
935
- @total_output_tokens += result.output_tokens || 0
936
-
937
- model = ConsoleAgent.configuration.resolved_model
938
- @token_usage[model][:input] += result.input_tokens || 0
939
- @token_usage[model][:output] += result.output_tokens || 0
940
- end
941
-
942
- def display_usage(result, show_session: false)
943
- input = result.input_tokens
944
- output = result.output_tokens
945
- return unless input || output
946
-
947
- parts = []
948
- parts << "in: #{input}" if input
949
- parts << "out: #{output}" if output
950
- parts << "total: #{result.total_tokens}"
951
-
952
- line = "\e[2m[tokens #{parts.join(' | ')}]\e[0m"
953
-
954
- if show_session && (@total_input_tokens + @total_output_tokens) > result.total_tokens
955
- line += "\e[2m [session: in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
956
- end
957
-
958
- $stdout.puts line
959
- end
960
-
961
- def with_console_capture(capture_io)
962
- old_stdout = $stdout
963
- $stdout = TeeIO.new(old_stdout, capture_io)
964
- yield
965
- ensure
966
- $stdout = old_stdout
967
- end
968
-
969
- def log_interactive_turn
970
- require 'console_agent/session_logger'
971
- session_attrs = {
972
- conversation: @history,
973
- input_tokens: @total_input_tokens,
974
- output_tokens: @total_output_tokens,
975
- code_executed: @last_interactive_code,
976
- code_output: @last_interactive_output,
977
- code_result: @last_interactive_result,
978
- executed: @last_interactive_executed,
979
- console_output: @interactive_console_capture&.string
980
- }
981
-
982
- if @interactive_session_id
983
- SessionLogger.update(@interactive_session_id, session_attrs)
47
+ when :@history
48
+ @engine.instance_variable_set(:@history, value)
984
49
  else
985
- @interactive_session_id = SessionLogger.log(
986
- session_attrs.merge(
987
- query: @interactive_query || '(interactive session)',
988
- mode: 'interactive',
989
- name: @interactive_session_name
990
- )
991
- )
992
- end
993
- end
994
-
995
- def finish_interactive_session
996
- require 'console_agent/session_logger'
997
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @interactive_start) * 1000).round
998
- if @interactive_session_id
999
- SessionLogger.update(@interactive_session_id,
1000
- conversation: @history,
1001
- input_tokens: @total_input_tokens,
1002
- output_tokens: @total_output_tokens,
1003
- code_executed: @last_interactive_code,
1004
- code_output: @last_interactive_output,
1005
- code_result: @last_interactive_result,
1006
- executed: @last_interactive_executed,
1007
- console_output: @interactive_console_capture&.string,
1008
- duration_ms: duration_ms
1009
- )
1010
- elsif @interactive_query
1011
- # Session was never created (e.g., only one turn that failed to log)
1012
- log_session(
1013
- query: @interactive_query,
1014
- conversation: @history,
1015
- mode: 'interactive',
1016
- code_executed: @last_interactive_code,
1017
- code_output: @last_interactive_output,
1018
- code_result: @last_interactive_result,
1019
- executed: @last_interactive_executed,
1020
- console_output: @interactive_console_capture&.string,
1021
- start_time: @interactive_start
1022
- )
1023
- end
1024
- end
1025
-
1026
- def log_session(attrs)
1027
- require 'console_agent/session_logger'
1028
- start_time = attrs.delete(:start_time)
1029
- duration_ms = if start_time
1030
- ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
1031
- end
1032
- SessionLogger.log(
1033
- attrs.merge(
1034
- input_tokens: @total_input_tokens,
1035
- output_tokens: @total_output_tokens,
1036
- duration_ms: duration_ms
1037
- )
1038
- )
1039
- end
1040
-
1041
- def display_session_summary
1042
- return if @total_input_tokens == 0 && @total_output_tokens == 0
1043
-
1044
- $stdout.puts "\e[2m[session totals — in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
1045
- end
1046
-
1047
- def display_cost_summary
1048
- if @token_usage.empty?
1049
- $stdout.puts "\e[2m No usage yet.\e[0m"
1050
- return
50
+ super
1051
51
  end
1052
-
1053
- total_cost = 0.0
1054
- $stdout.puts "\e[36m Cost estimate:\e[0m"
1055
-
1056
- @token_usage.each do |model, usage|
1057
- pricing = Configuration::PRICING[model]
1058
- input_str = "in: #{format_tokens(usage[:input])}"
1059
- output_str = "out: #{format_tokens(usage[:output])}"
1060
-
1061
- if pricing
1062
- cost = (usage[:input] * pricing[:input]) + (usage[:output] * pricing[:output])
1063
- total_cost += cost
1064
- $stdout.puts "\e[2m #{model}: #{input_str} #{output_str} ~$#{'%.2f' % cost}\e[0m"
1065
- else
1066
- $stdout.puts "\e[2m #{model}: #{input_str} #{output_str} (pricing unknown)\e[0m"
1067
- end
1068
- end
1069
-
1070
- $stdout.puts "\e[36m Total: ~$#{'%.2f' % total_cost}\e[0m"
1071
52
  end
1072
53
 
1073
- def upgrade_to_thinking_model
1074
- config = ConsoleAgent.configuration
1075
- current = config.resolved_model
1076
- thinking = config.resolved_thinking_model
1077
-
1078
- if current == thinking
1079
- $stdout.puts "\e[36m Already using thinking model (#{current}).\e[0m"
1080
- else
1081
- config.model = thinking
1082
- @provider = nil
1083
- $stdout.puts "\e[36m Switched to thinking model: #{thinking}\e[0m"
1084
- end
1085
- end
54
+ private
1086
55
 
1087
- def on_thinking_model?
1088
- config = ConsoleAgent.configuration
1089
- config.resolved_model == config.resolved_thinking_model
56
+ # Expose send methods for spec compatibility
57
+ def send_query(query, conversation: nil)
58
+ @engine.send(:send_query, query, conversation: conversation)
1090
59
  end
1091
60
 
1092
- # Replace older execution outputs with short references.
1093
- # Keeps the last RECENT_OUTPUTS_TO_KEEP outputs in full.
1094
61
  def trim_old_outputs(messages)
1095
- # Find indices of messages with output_id (execution outputs and tool results)
1096
- output_indices = messages.each_with_index
1097
- .select { |m, _| m[:output_id] }
1098
- .map { |_, i| i }
1099
-
1100
- if output_indices.length <= RECENT_OUTPUTS_TO_KEEP
1101
- return messages.map { |m| m.except(:output_id) }
1102
- end
1103
-
1104
- # Indices to trim (all except the most recent N)
1105
- trim_indices = output_indices[0..-(RECENT_OUTPUTS_TO_KEEP + 1)]
1106
- messages.each_with_index.map do |msg, i|
1107
- if trim_indices.include?(i)
1108
- trim_message(msg)
1109
- else
1110
- msg.except(:output_id)
1111
- end
1112
- end
1113
- end
1114
-
1115
- # Replace the content of a message with a short reference to the stored output.
1116
- # Handles both regular messages and tool result messages (Anthropic/OpenAI formats).
1117
- def trim_message(msg)
1118
- ref = "[Output omitted — use recall_output tool with id #{msg[:output_id]} to retrieve]"
1119
-
1120
- if msg[:content].is_a?(Array)
1121
- # Anthropic tool_result format: [{ 'type' => 'tool_result', 'tool_use_id' => '...', 'content' => '...' }]
1122
- trimmed_content = msg[:content].map do |block|
1123
- if block.is_a?(Hash) && block['type'] == 'tool_result'
1124
- block.merge('content' => ref)
1125
- else
1126
- block
1127
- end
1128
- end
1129
- { role: msg[:role], content: trimmed_content }
1130
- elsif msg[:role].to_s == 'tool'
1131
- # OpenAI tool result format
1132
- msg.except(:output_id).merge(content: ref)
1133
- else
1134
- # Regular user message (code execution result)
1135
- first_line = msg[:content].to_s.lines.first&.strip || msg[:content]
1136
- { role: msg[:role], content: "#{first_line}\n#{ref}" }
1137
- end
1138
- end
1139
-
1140
- def warn_if_history_large
1141
- chars = @history.sum { |m| m[:content].to_s.length }
1142
-
1143
- if chars > 50_000 && !@compact_warned
1144
- @compact_warned = true
1145
- $stdout.puts "\e[33m Conversation is getting large (~#{format_tokens(chars)} chars). Consider running /compact to reduce context size.\e[0m"
1146
- end
1147
- end
1148
-
1149
- def compact_history
1150
- if @history.length < 6
1151
- $stdout.puts "\e[33m History too short to compact (#{@history.length} messages). Need at least 6.\e[0m"
1152
- return
1153
- end
1154
-
1155
- before_chars = @history.sum { |m| m[:content].to_s.length }
1156
- before_count = @history.length
1157
-
1158
- # Extract successfully executed code before summarizing
1159
- executed_code = extract_executed_code(@history)
1160
-
1161
- $stdout.puts "\e[2m Compacting #{before_count} messages (~#{format_tokens(before_chars)} chars)...\e[0m"
1162
-
1163
- system_prompt = <<~PROMPT
1164
- You are a conversation summarizer. The user will provide a conversation history from a Rails console AI assistant session.
1165
-
1166
- Produce a concise summary that captures:
1167
- - What the user has been working on and their goals
1168
- - Key findings and data discovered (include specific values, IDs, record counts)
1169
- - Current state: what worked, what failed, where things stand
1170
- - Important variable names, model names, or table names referenced
1171
-
1172
- Do NOT include code that was executed — that will be preserved separately.
1173
- Be concise but preserve all information that would be needed to continue the conversation naturally.
1174
- Do NOT include any preamble — just output the summary directly.
1175
- PROMPT
1176
-
1177
- history_text = @history.map { |m| "#{m[:role]}: #{m[:content]}" }.join("\n\n")
1178
- messages = [{ role: :user, content: "Summarize this conversation history:\n\n#{history_text}" }]
1179
-
1180
- begin
1181
- result = provider.chat(messages, system_prompt: system_prompt)
1182
- track_usage(result)
1183
-
1184
- summary = result.text.to_s.strip
1185
- if summary.empty?
1186
- $stdout.puts "\e[33m Compaction failed: empty summary returned.\e[0m"
1187
- return
1188
- end
1189
-
1190
- content = "CONVERSATION SUMMARY (compacted):\n#{summary}"
1191
- unless executed_code.empty?
1192
- content += "\n\nCODE EXECUTED THIS SESSION (preserved for continuation):\n#{executed_code}"
1193
- end
1194
-
1195
- @history = [{ role: :user, content: content }]
1196
- @compact_warned = false
1197
-
1198
- after_chars = @history.first[:content].length
1199
- $stdout.puts "\e[36m Compacted: #{before_count} messages -> 1 summary (~#{format_tokens(before_chars)} -> ~#{format_tokens(after_chars)} chars)\e[0m"
1200
- summary.each_line { |line| $stdout.puts "\e[2m #{line.rstrip}\e[0m" }
1201
- if !executed_code.empty?
1202
- $stdout.puts "\e[2m (preserved #{executed_code.scan(/```ruby/).length} executed code block(s))\e[0m"
1203
- end
1204
- display_usage(result)
1205
- rescue => e
1206
- $stdout.puts "\e[31m Compaction failed: #{e.message}\e[0m"
1207
- end
1208
- end
1209
-
1210
- # Extracts code blocks that were successfully executed from conversation history.
1211
- # Looks for:
1212
- # 1. Assistant messages with ```ruby blocks followed by "Code was executed." user messages
1213
- # 2. execute_plan tool calls followed by results without ERROR
1214
- # Skips code that failed or was declined.
1215
- def extract_executed_code(history)
1216
- code_blocks = []
1217
- history.each_cons(2) do |msg, next_msg|
1218
- # Pattern 1: Assistant ```ruby blocks with successful execution
1219
- if msg[:role].to_s == 'assistant' && next_msg[:role].to_s == 'user'
1220
- content = msg[:content].to_s
1221
- next_content = next_msg[:content].to_s
1222
-
1223
- if next_content.start_with?('Code was executed.')
1224
- content.scan(/```ruby\s*\n(.*?)```/m).each do |match|
1225
- code = match[0].strip
1226
- next if code.empty?
1227
- result_summary = next_content[0..200].gsub("\n", "\n# ")
1228
- code_blocks << "```ruby\n#{code}\n```\n# #{result_summary}"
1229
- end
1230
- end
1231
- end
1232
-
1233
- # Pattern 2: execute_plan tool calls in provider-formatted messages
1234
- if msg[:role].to_s == 'assistant' && msg[:content].is_a?(Array)
1235
- msg[:content].each do |block|
1236
- next unless block.is_a?(Hash) && block['type'] == 'tool_use' && block['name'] == 'execute_plan'
1237
- input = block['input'] || {}
1238
- steps = input['steps'] || []
1239
-
1240
- # Find the matching tool_result in subsequent messages
1241
- tool_id = block['id']
1242
- result_msg = find_tool_result(history, tool_id)
1243
- next unless result_msg
1244
-
1245
- result_text = result_msg.to_s
1246
- # Extract only steps that succeeded (no ERROR in their result)
1247
- steps.each_with_index do |step, i|
1248
- step_num = i + 1
1249
- # Check if this specific step had an error
1250
- step_section = result_text[/Step #{step_num}\b.*?(?=Step #{step_num + 1}\b|\z)/m] || ''
1251
- next if step_section.include?('ERROR:')
1252
- next if step_section.include?('User declined')
1253
-
1254
- code = step['code'].to_s.strip
1255
- next if code.empty?
1256
- desc = step['description'] || "Step #{step_num}"
1257
- code_blocks << "```ruby\n# #{desc}\n#{code}\n```"
1258
- end
1259
- end
1260
- end
1261
- end
1262
- code_blocks.join("\n\n")
1263
- end
1264
-
1265
- def find_tool_result(history, tool_id)
1266
- history.each do |msg|
1267
- next unless msg[:content].is_a?(Array)
1268
- msg[:content].each do |block|
1269
- next unless block.is_a?(Hash)
1270
- if block['type'] == 'tool_result' && block['tool_use_id'] == tool_id
1271
- return block['content']
1272
- end
1273
- # OpenAI format
1274
- if msg[:role].to_s == 'tool' && msg[:tool_call_id] == tool_id
1275
- return msg[:content]
1276
- end
1277
- end
1278
- end
1279
- nil
1280
- end
1281
-
1282
- def display_conversation
1283
- if @history.empty?
1284
- @interactive_old_stdout.puts "\e[2m (no conversation history yet)\e[0m"
1285
- return
1286
- end
1287
-
1288
- trimmed = trim_old_outputs(@history)
1289
- @interactive_old_stdout.puts "\e[36m Conversation (#{trimmed.length} messages, as sent to LLM):\e[0m"
1290
- trimmed.each_with_index do |msg, i|
1291
- role = msg[:role].to_s
1292
- content = msg[:content].to_s
1293
- label = role == 'user' ? "\e[33m[user]\e[0m" : "\e[36m[assistant]\e[0m"
1294
- @interactive_old_stdout.puts "#{label} #{content}"
1295
- @interactive_old_stdout.puts if i < trimmed.length - 1
1296
- end
1297
- end
1298
-
1299
- def display_help
1300
- auto = ConsoleAgent.configuration.auto_execute ? 'ON' : 'OFF'
1301
- @interactive_old_stdout.puts "\e[36m Commands:\e[0m"
1302
- @interactive_old_stdout.puts "\e[2m /auto Toggle auto-execute (currently #{auto}) (Shift-Tab)\e[0m"
1303
- @interactive_old_stdout.puts "\e[2m /think Switch to thinking model\e[0m"
1304
- @interactive_old_stdout.puts "\e[2m /compact Summarize conversation to reduce context\e[0m"
1305
- @interactive_old_stdout.puts "\e[2m /usage Show session token totals\e[0m"
1306
- @interactive_old_stdout.puts "\e[2m /cost Show cost estimate by model\e[0m"
1307
- @interactive_old_stdout.puts "\e[2m /name <lbl> Name this session for easy resume\e[0m"
1308
- @interactive_old_stdout.puts "\e[2m /context Show conversation history sent to the LLM\e[0m"
1309
- @interactive_old_stdout.puts "\e[2m /system Show the system prompt\e[0m"
1310
- @interactive_old_stdout.puts "\e[2m /expand <id> Show full omitted output\e[0m"
1311
- @interactive_old_stdout.puts "\e[2m /debug Toggle debug summaries (context stats, cost per call)\e[0m"
1312
- @interactive_old_stdout.puts "\e[2m > code Execute Ruby directly (skip LLM)\e[0m"
1313
- @interactive_old_stdout.puts "\e[2m exit/quit Leave interactive mode\e[0m"
1314
- end
1315
-
1316
- def display_exit_info
1317
- display_session_summary
1318
- if @interactive_session_id
1319
- $stdout.puts "\e[36mSession ##{@interactive_session_id} saved.\e[0m"
1320
- if @interactive_session_name
1321
- $stdout.puts "\e[2m Resume with: ai_resume \"#{@interactive_session_name}\"\e[0m"
1322
- else
1323
- $stdout.puts "\e[2m Name it: ai_name #{@interactive_session_id}, \"descriptive_name\"\e[0m"
1324
- $stdout.puts "\e[2m Resume it: ai_resume #{@interactive_session_id}\e[0m"
1325
- end
1326
- end
1327
- $stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
62
+ @engine.send(:trim_old_outputs, messages)
1328
63
  end
1329
64
  end
1330
65
  end