console_agent 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f82de0e3bdd6d1e3189c3b1bd0c88c386a6b31b4dff317656365352490f06c9
4
- data.tar.gz: 1347a93e946c254809cdf6bfa90e3dea0378b7aef9a5e04950bdf0151569cd79
3
+ metadata.gz: dc46d0592feb84b4d85481d1535dccbe417a4445593828424c12a84d96fcbc9c
4
+ data.tar.gz: 10fe29dc81cc425a498c6e7d6c6b82aaa586ec674c081ba3f7b5b1143b68df18
5
5
  SHA512:
6
- metadata.gz: cec5e56aaddea19c5d1b9046fcbd7fd4707d16efa3fab65f9977703e24d28d177c12331c9cba7590048b1ad2f412d67bcb9ffe78c5cef288d6da40916cb53a86
7
- data.tar.gz: 9e67f0c30bbd38561c400523ca19240f931ff5c08b353fa545037472b6b2195f931b5987b21d3c643a35e2cb3377647447cc676aa7544ba5a2ccec2a505f7e16
6
+ metadata.gz: 86760d6c3b7c4920fc2c01741be308fc3d3f133e264c8dc37cab6b1ab90e9b920a410d57c86d8f96e743396d6919735d7fad62ee584667c8ea177c4825a12d05
7
+ data.tar.gz: 6446b9b2af4803ccd860fd109484ef37de87850517ad117eb52892974a65a017c1b03188dfc1eb7f24aad3859749ce4f6d282220c8d5d78238e67cfdb7438def
data/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.10.0]
6
+
7
+ - Add `/expand` command to view previous results
8
+ - Exclude previous output from context; add tool for LLM to retrieve it on demand
9
+ - Show summarized info per LLM call in `/debug`
10
+
11
+ ## [0.9.0]
12
+
13
+ - Add `/system` and `/context` commands to inspect what is being sent
14
+ - Omit huge output from tool results
15
+ - Don't cancel code execution on incorrect prompt answers
16
+ - Preserve code blocks when compacting; require manual `/compact`
17
+ - Fix authentication when neither method was applied
18
+ - Remove prompt to upgrade model on excessive tool calls
19
+
5
20
  ## [0.8.0]
6
21
 
7
22
  - Add authentication function support so host apps can avoid using basic auth
data/README.md CHANGED
@@ -79,7 +79,10 @@ end
79
79
  | `/usage` | Show token stats |
80
80
  | `/cost` | Show per-model cost breakdown |
81
81
  | `/think` | Upgrade to thinking model (Opus) for the rest of the session |
82
- | `/debug` | Toggle raw API output |
82
+ | `/debug` | Toggle debug summaries (context stats, cost per call) |
83
+ | `/expand <id>` | Show full omitted output |
84
+ | `/context` | Show conversation history as sent to the LLM |
85
+ | `/system` | Show the system prompt |
83
86
  | `/name <label>` | Name the session for easy resume |
84
87
 
85
88
  Prefix input with `>` to run Ruby directly (no LLM round-trip). The result is added to conversation context.
@@ -96,6 +99,8 @@ Say "think harder" in any query to auto-upgrade to the thinking model for that s
96
99
  - **App guide** — `ai_init` generates a guide injected into every system prompt
97
100
  - **Sessions** — name, list, and resume interactive conversations (`ai_setup` to enable)
98
101
  - **History compaction** — `/compact` summarizes long conversations to reduce cost and latency
102
+ - **Output trimming** — older execution outputs are automatically replaced with references; the LLM can recall them on demand via `recall_output`, and you can `/expand <id>` to see them
103
+ - **Debug mode** — `/debug` shows context breakdown, token counts, and per-call cost estimates before and after each LLM call
99
104
 
100
105
  ## Configuration
101
106
 
@@ -48,6 +48,10 @@ module ConsoleAgent
48
48
 
49
49
  def initialize(binding_context)
50
50
  @binding_context = binding_context
51
+ @omitted_outputs = {}
52
+ @omitted_counter = 0
53
+ @output_store = {}
54
+ @output_counter = 0
51
55
  end
52
56
 
53
57
  def extract_code(response)
@@ -107,6 +111,20 @@ module ConsoleAgent
107
111
  @last_output
108
112
  end
109
113
 
114
+ def expand_output(id)
115
+ @omitted_outputs[id]
116
+ end
117
+
118
+ def store_output(content)
119
+ @output_counter += 1
120
+ @output_store[@output_counter] = content
121
+ @output_counter
122
+ end
123
+
124
+ def recall_output(id)
125
+ @output_store[id]
126
+ end
127
+
110
128
  def last_answer
111
129
  @last_answer
112
130
  end
@@ -185,7 +203,10 @@ module ConsoleAgent
185
203
  parts = []
186
204
  parts << "#{omitted_lines} lines" if omitted_lines > 0
187
205
  parts << "#{omitted_chars} chars" if omitted_chars > 0
188
- $stdout.puts colorize(" (omitting #{parts.join(', ')})", :yellow)
206
+
207
+ @omitted_counter += 1
208
+ @omitted_outputs[@omitted_counter] = full
209
+ $stdout.puts colorize(" (omitting #{parts.join(', ')}) /expand #{@omitted_counter} to see all", :yellow)
189
210
  end
190
211
  end
191
212
 
@@ -41,24 +41,27 @@ module ConsoleAgent
41
41
  def debug_request(url, body)
42
42
  return unless config.debug
43
43
 
44
- $stderr.puts "\e[33m--- ConsoleAgent DEBUG: REQUEST ---\e[0m"
45
- $stderr.puts "\e[33mURL: #{url}\e[0m"
46
- parsed = body.is_a?(String) ? JSON.parse(body) : body
47
- $stderr.puts "\e[33m#{JSON.pretty_generate(parsed)}\e[0m"
48
- $stderr.puts "\e[33m--- END REQUEST ---\e[0m"
49
- rescue => e
50
- $stderr.puts "\e[33m[debug] #{body}\e[0m"
44
+ parsed = body.is_a?(String) ? (JSON.parse(body) rescue nil) : body
45
+ if parsed
46
+ # Support both symbol and string keys
47
+ model = parsed[:model] || parsed['model']
48
+ msgs = parsed[:messages] || parsed['messages']
49
+ sys = parsed[:system] || parsed['system']
50
+ tools = parsed[:tools] || parsed['tools']
51
+ $stderr.puts "\e[33m[debug] POST #{url} | model: #{model} | #{msgs&.length || 0} msgs | system: #{sys.to_s.length} chars | #{tools&.length || 0} tools\e[0m"
52
+ else
53
+ $stderr.puts "\e[33m[debug] POST #{url}\e[0m"
54
+ end
51
55
  end
52
56
 
53
57
  def debug_response(body)
54
58
  return unless config.debug
55
59
 
56
- $stderr.puts "\e[36m--- ConsoleAgent DEBUG: RESPONSE ---\e[0m"
57
- parsed = body.is_a?(String) ? JSON.parse(body) : body
58
- $stderr.puts "\e[36m#{JSON.pretty_generate(parsed)}\e[0m"
59
- $stderr.puts "\e[36m--- END RESPONSE ---\e[0m"
60
- rescue => e
61
- $stderr.puts "\e[36m[debug] #{body}\e[0m"
60
+ parsed = body.is_a?(String) ? (JSON.parse(body) rescue nil) : body
61
+ if parsed && parsed['usage']
62
+ u = parsed['usage']
63
+ $stderr.puts "\e[36m[debug] response: #{parsed['stop_reason']} | in: #{u['input_tokens']} out: #{u['output_tokens']}\e[0m"
64
+ end
62
65
  end
63
66
 
64
67
  def parse_response(response)
@@ -285,6 +285,17 @@ module ConsoleAgent
285
285
  next
286
286
  end
287
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
+
288
299
  if input == '/think'
289
300
  upgrade_to_thinking_model
290
301
  next
@@ -326,7 +337,8 @@ module ConsoleAgent
326
337
 
327
338
  context_msg = "User directly executed code: `#{raw_code}`"
328
339
  context_msg += "\n#{result_str}" unless output_parts.empty?
329
- @history << { role: :user, content: context_msg }
340
+ output_id = output_parts.empty? ? nil : @executor.store_output(result_str)
341
+ @history << { role: :user, content: context_msg, output_id: output_id }
330
342
 
331
343
  @interactive_query ||= input
332
344
  @last_interactive_code = raw_code
@@ -459,7 +471,8 @@ module ConsoleAgent
459
471
  unless output_parts.empty?
460
472
  result_str = output_parts.join("\n\n")
461
473
  result_str = result_str[0..1000] + '...' if result_str.length > 1000
462
- @history << { role: :user, content: "Code was executed. #{result_str}" }
474
+ output_id = @executor.store_output(result_str)
475
+ @history << { role: :user, content: "Code was executed. #{result_str}", output_id: output_id }
463
476
  end
464
477
 
465
478
  :success
@@ -547,6 +560,10 @@ module ConsoleAgent
547
560
  prompt.strip
548
561
  end
549
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
+
550
567
  def send_query(query, conversation: nil)
551
568
  ConsoleAgent.configuration.validate!
552
569
 
@@ -556,6 +573,8 @@ module ConsoleAgent
556
573
  [{ role: :user, content: query }]
557
574
  end
558
575
 
576
+ messages = trim_old_outputs(messages) if conversation
577
+
559
578
  send_query_with_tools(messages)
560
579
  end
561
580
 
@@ -586,6 +605,10 @@ module ConsoleAgent
586
605
  $stdout.puts "\e[2m #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}\e[0m"
587
606
  end
588
607
 
608
+ if ConsoleAgent.configuration.debug
609
+ debug_pre_call(round, messages, active_system_prompt, tools, total_input, total_output)
610
+ end
611
+
589
612
  begin
590
613
  result = with_escape_monitoring do
591
614
  provider.chat_with_tools(messages, tools: tools, system_prompt: active_system_prompt)
@@ -596,6 +619,10 @@ module ConsoleAgent
596
619
  total_input += result.input_tokens || 0
597
620
  total_output += result.output_tokens || 0
598
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
+
599
626
  break unless result.tool_use?
600
627
 
601
628
  # Buffer thinking text for display before next LLM call
@@ -624,10 +651,14 @@ module ConsoleAgent
624
651
  end
625
652
 
626
653
  if ConsoleAgent.configuration.debug
627
- $stderr.puts "\e[35m[debug tool result] #{tool_result}\e[0m"
654
+ $stderr.puts "\e[35m[debug] tool result (#{tool_result.to_s.length} chars)\e[0m"
628
655
  end
629
656
 
630
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
631
662
  messages << tool_msg
632
663
  new_messages << tool_msg
633
664
  end
@@ -712,6 +743,89 @@ module ConsoleAgent
712
743
  status
713
744
  end
714
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
+
715
829
  def format_tokens(count)
716
830
  if count >= 1_000_000
717
831
  "#{(count / 1_000_000.0).round(1)}M"
@@ -975,6 +1089,54 @@ module ConsoleAgent
975
1089
  config.resolved_model == config.resolved_thinking_model
976
1090
  end
977
1091
 
1092
+ # Replace older execution outputs with short references.
1093
+ # Keeps the last RECENT_OUTPUTS_TO_KEEP outputs in full.
1094
+ 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
+
978
1140
  def warn_if_history_large
979
1141
  chars = @history.sum { |m| m[:content].to_s.length }
980
1142
 
@@ -1123,13 +1285,14 @@ module ConsoleAgent
1123
1285
  return
1124
1286
  end
1125
1287
 
1126
- @interactive_old_stdout.puts "\e[36m Conversation (#{@history.length} messages):\e[0m"
1127
- @history.each_with_index do |msg, i|
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|
1128
1291
  role = msg[:role].to_s
1129
1292
  content = msg[:content].to_s
1130
1293
  label = role == 'user' ? "\e[33m[user]\e[0m" : "\e[36m[assistant]\e[0m"
1131
1294
  @interactive_old_stdout.puts "#{label} #{content}"
1132
- @interactive_old_stdout.puts if i < @history.length - 1
1295
+ @interactive_old_stdout.puts if i < trimmed.length - 1
1133
1296
  end
1134
1297
  end
1135
1298
 
@@ -1144,7 +1307,8 @@ module ConsoleAgent
1144
1307
  @interactive_old_stdout.puts "\e[2m /name <lbl> Name this session for easy resume\e[0m"
1145
1308
  @interactive_old_stdout.puts "\e[2m /context Show conversation history sent to the LLM\e[0m"
1146
1309
  @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"
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"
1148
1312
  @interactive_old_stdout.puts "\e[2m > code Execute Ruby directly (skip LLM)\e[0m"
1149
1313
  @interactive_old_stdout.puts "\e[2m exit/quit Leave interactive mode\e[0m"
1150
1314
  end
@@ -170,6 +170,24 @@ module ConsoleAgent
170
170
  handler: ->(args) { code.search_code(args['query'], args['directory']) }
171
171
  )
172
172
 
173
+ if @executor
174
+ register(
175
+ name: 'recall_output',
176
+ description: 'Retrieve a previous code execution output that was omitted from the conversation to save context. Use the output id shown in the "[Output omitted]" placeholder.',
177
+ parameters: {
178
+ 'type' => 'object',
179
+ 'properties' => {
180
+ 'id' => { 'type' => 'integer', 'description' => 'The output id to retrieve' }
181
+ },
182
+ 'required' => ['id']
183
+ },
184
+ handler: ->(args) {
185
+ result = @executor.recall_output(args['id'].to_i)
186
+ result || "No output found with id #{args['id']}"
187
+ }
188
+ )
189
+ end
190
+
173
191
  unless @mode == :init
174
192
  register(
175
193
  name: 'ask_user',
@@ -1,3 +1,3 @@
1
1
  module ConsoleAgent
2
- VERSION = '0.9.0'.freeze
2
+ VERSION = '0.10.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: console_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr