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.
@@ -43,11 +43,16 @@ module ConsoleAgent
43
43
  class Executor
44
44
  CODE_REGEX = /```ruby\s*\n(.*?)```/m
45
45
 
46
- attr_reader :binding_context, :last_error
46
+ attr_reader :binding_context, :last_error, :last_safety_error, :last_safety_exception
47
47
  attr_accessor :on_prompt
48
48
 
49
- def initialize(binding_context)
49
+ def initialize(binding_context, channel: nil)
50
50
  @binding_context = binding_context
51
+ @channel = channel
52
+ @omitted_outputs = {}
53
+ @omitted_counter = 0
54
+ @output_store = {}
55
+ @output_counter = 0
51
56
  end
52
57
 
53
58
  def extract_code(response)
@@ -55,18 +60,27 @@ module ConsoleAgent
55
60
  match ? match[1].strip : ''
56
61
  end
57
62
 
63
+ # Matches any fenced code block (```anything ... ```)
64
+ ANY_CODE_FENCE_REGEX = /```\w*\s*\n.*?```/m
65
+
58
66
  def display_response(response)
59
67
  code = extract_code(response)
60
- explanation = response.gsub(CODE_REGEX, '').strip
61
-
62
- $stdout.puts
63
- $stdout.puts colorize(explanation, :cyan) unless explanation.empty?
68
+ explanation = response.gsub(ANY_CODE_FENCE_REGEX, '').strip
64
69
 
65
- unless code.empty?
70
+ if @channel
66
71
  $stdout.puts
67
- $stdout.puts colorize("# Generated code:", :yellow)
68
- $stdout.puts highlight_code(code)
72
+ @channel.display(explanation) unless explanation.empty?
73
+ @channel.display_code(code) unless code.empty?
74
+ else
69
75
  $stdout.puts
76
+ $stdout.puts colorize(explanation, :cyan) unless explanation.empty?
77
+
78
+ unless code.empty?
79
+ $stdout.puts
80
+ $stdout.puts colorize("# Generated code:", :yellow)
81
+ $stdout.puts highlight_code(code)
82
+ $stdout.puts
83
+ end
70
84
  end
71
85
 
72
86
  code
@@ -76,29 +90,58 @@ module ConsoleAgent
76
90
  return nil if code.nil? || code.strip.empty?
77
91
 
78
92
  @last_error = nil
93
+ @last_safety_error = false
94
+ @last_safety_exception = nil
79
95
  captured_output = StringIO.new
80
96
  old_stdout = $stdout
81
97
  # Tee output: capture it and also print to the real stdout
82
98
  $stdout = TeeIO.new(old_stdout, captured_output)
83
99
 
84
- result = binding_context.eval(code, "(console_agent)", 1)
100
+ result = with_safety_guards do
101
+ binding_context.eval(code, "(console_agent)", 1)
102
+ end
85
103
 
86
104
  $stdout = old_stdout
105
+
106
+ # Send captured puts output through channel before the return value
107
+ if @channel && !captured_output.string.empty?
108
+ @channel.display_result_output(captured_output.string)
109
+ end
110
+
87
111
  display_result(result)
88
112
 
89
113
  @last_output = captured_output.string
90
114
  result
115
+ rescue ConsoleAgent::SafetyError => e
116
+ $stdout = old_stdout if old_stdout
117
+ @last_error = "SafetyError: #{e.message}"
118
+ @last_safety_error = true
119
+ @last_safety_exception = e
120
+ display_error("Blocked: #{e.message}")
121
+ @last_output = captured_output&.string
122
+ nil
91
123
  rescue SyntaxError => e
92
124
  $stdout = old_stdout if old_stdout
93
125
  @last_error = "SyntaxError: #{e.message}"
94
- $stderr.puts colorize(@last_error, :red)
126
+ display_error(@last_error)
95
127
  @last_output = nil
96
128
  nil
97
129
  rescue => e
98
130
  $stdout = old_stdout if old_stdout
131
+ # Check if a SafetyError is wrapped (e.g. ActiveRecord::StatementInvalid wrapping our error)
132
+ if safety_error?(e)
133
+ safety_exc = extract_safety_exception(e)
134
+ safety_msg = safety_exc ? safety_exc.message : e.message
135
+ @last_error = "SafetyError: #{safety_msg}"
136
+ @last_safety_error = true
137
+ @last_safety_exception = safety_exc
138
+ display_error("Blocked: #{safety_msg}")
139
+ @last_output = captured_output&.string
140
+ return nil
141
+ end
99
142
  @last_error = "#{e.class}: #{e.message}"
100
- $stderr.puts colorize("Error: #{@last_error}", :red)
101
- e.backtrace.first(3).each { |line| $stderr.puts colorize(" #{line}", :red) }
143
+ display_error("Error: #{@last_error}")
144
+ e.backtrace.first(3).each { |line| display_error(" #{line}") }
102
145
  @last_output = captured_output&.string
103
146
  nil
104
147
  end
@@ -107,6 +150,20 @@ module ConsoleAgent
107
150
  @last_output
108
151
  end
109
152
 
153
+ def expand_output(id)
154
+ @omitted_outputs[id]
155
+ end
156
+
157
+ def store_output(content)
158
+ @output_counter += 1
159
+ @output_store[@output_counter] = content
160
+ @output_counter
161
+ end
162
+
163
+ def recall_output(id)
164
+ @output_store[id]
165
+ end
166
+
110
167
  def last_answer
111
168
  @last_answer
112
169
  end
@@ -120,24 +177,54 @@ module ConsoleAgent
120
177
 
121
178
  @last_cancelled = false
122
179
  @last_answer = nil
123
- $stdout.print colorize("Execute? [y/N/edit] ", :yellow)
124
- @on_prompt&.call
125
- answer = $stdin.gets.to_s.strip.downcase
180
+ prompt = execute_prompt
181
+
182
+ if @channel
183
+ answer = @channel.confirm(prompt)
184
+ else
185
+ $stdout.print colorize(prompt, :yellow)
186
+ @on_prompt&.call
187
+ answer = $stdin.gets.to_s.strip.downcase
188
+ echo_stdin(answer)
189
+ end
126
190
  @last_answer = answer
127
- echo_stdin(answer)
128
191
 
129
192
  loop do
130
193
  case answer
131
194
  when 'y', 'yes', 'a'
132
- return execute(code)
195
+ result = execute(code)
196
+ if @last_safety_error
197
+ return nil unless danger_allowed?
198
+ return offer_danger_retry(code)
199
+ end
200
+ return result
201
+ when 'd', 'danger'
202
+ unless danger_allowed?
203
+ display_error("Safety guards cannot be disabled in this channel.")
204
+ return nil
205
+ end
206
+ if @channel
207
+ @channel.display_error("Executing with safety guards disabled.")
208
+ else
209
+ $stdout.puts colorize("Executing with safety guards disabled.", :red)
210
+ end
211
+ return execute_unsafe(code)
133
212
  when 'e', 'edit'
134
- edited = open_in_editor(code)
213
+ edited = if @channel && @channel.supports_editing?
214
+ @channel.edit_code(code)
215
+ else
216
+ open_in_editor(code)
217
+ end
135
218
  if edited && edited != code
136
219
  $stdout.puts colorize("# Edited code:", :yellow)
137
220
  $stdout.puts highlight_code(edited)
138
- $stdout.print colorize("Execute edited code? [y/N] ", :yellow)
139
- edit_answer = $stdin.gets.to_s.strip.downcase
140
- echo_stdin(edit_answer)
221
+ if @channel
222
+ edit_answer = @channel.confirm("Execute edited code? [y/N] ")
223
+ else
224
+ $stdout.print colorize("Execute edited code? [y/N] ", :yellow)
225
+ edit_answer = $stdin.gets.to_s.strip.downcase
226
+ echo_stdin(edit_answer)
227
+ end
141
228
  if edit_answer == 'y'
142
229
  return execute(edited)
143
230
  else
@@ -152,40 +239,166 @@ module ConsoleAgent
152
239
  @last_cancelled = true
153
240
  return nil
154
241
  else
155
- $stdout.print colorize("Execute? [y/N/edit] ", :yellow)
156
- @on_prompt&.call
157
- answer = $stdin.gets.to_s.strip.downcase
242
+ if @channel
243
+ answer = @channel.confirm(prompt)
244
+ else
245
+ $stdout.print colorize(prompt, :yellow)
246
+ @on_prompt&.call
247
+ answer = $stdin.gets.to_s.strip.downcase
248
+ echo_stdin(answer)
249
+ end
158
250
  @last_answer = answer
159
- echo_stdin(answer)
160
251
  end
161
252
  end
162
253
  end
163
254
 
255
+ def offer_danger_retry(code)
256
+ return nil unless danger_allowed?
257
+
258
+ exc = @last_safety_exception
259
+ blocked_key = exc&.blocked_key
260
+ guard = exc&.guard
261
+
262
+ if blocked_key && guard
263
+ allow_desc = allow_description(guard, blocked_key)
264
+ $stdout.puts colorize(" [d] re-run with all safe mode disabled", :yellow)
265
+ $stdout.puts colorize(" [a] allow #{allow_desc} for this session", :yellow)
266
+ $stdout.puts colorize(" [N] cancel", :yellow)
267
+ prompt_text = "Choice: "
268
+ else
269
+ prompt_text = "Re-run with safe mode disabled? [y/N] "
270
+ end
271
+
272
+ if @channel
273
+ answer = @channel.confirm(prompt_text)
274
+ else
275
+ $stdout.print colorize(prompt_text, :yellow)
276
+ answer = $stdin.gets.to_s.strip.downcase
277
+ echo_stdin(answer)
278
+ end
279
+
280
+ case answer
281
+ when 'a', 'allow'
282
+ if blocked_key && guard
283
+ ConsoleAgent.configuration.safety_guards.allow(guard, blocked_key)
284
+ allow_desc = allow_description(guard, blocked_key)
285
+ $stdout.puts colorize("Allowed #{allow_desc} for this session.", :green)
286
+ return execute(code)
287
+ else
288
+ if @channel
289
+ answer = @channel.confirm("Nothing to allow — re-run with safe mode disabled instead? [y/N] ")
290
+ else
291
+ $stdout.puts colorize("Nothing to allow — re-run with safe mode disabled instead? [y/N]", :yellow)
292
+ answer = $stdin.gets.to_s.strip.downcase
293
+ echo_stdin(answer)
294
+ end
295
+ end
296
+ when 'd', 'danger', 'y', 'yes'
297
+ $stdout.puts colorize("Executing with safety guards disabled.", :red)
298
+ return execute_unsafe(code)
299
+ end
300
+
301
+ $stdout.puts colorize("Cancelled.", :yellow)
302
+ nil
303
+ end
304
+
164
305
  private
165
306
 
307
+ def danger_allowed?
308
+ @channel.nil? || @channel.supports_danger?
309
+ end
310
+
311
+ def display_error(msg)
312
+ if @channel
313
+ @channel.display_error(msg)
314
+ else
315
+ $stderr.puts colorize(msg, :red)
316
+ end
317
+ end
318
+
319
+ def allow_description(guard, blocked_key)
320
+ case guard
321
+ when :database_writes
322
+ "all writes to #{blocked_key}"
323
+ when :http_mutations
324
+ "all HTTP mutations to #{blocked_key}"
325
+ else
326
+ "#{blocked_key} for :#{guard}"
327
+ end
328
+ end
329
+
330
+ def execute_unsafe(code)
331
+ guards = ConsoleAgent.configuration.safety_guards
332
+ guards.disable!
333
+ execute(code)
334
+ ensure
335
+ guards.enable!
336
+ end
337
+
338
+ def execute_prompt
339
+ guards = ConsoleAgent.configuration.safety_guards
340
+ if !guards.empty? && guards.enabled? && danger_allowed?
341
+ "Execute? [y/N/edit/danger] "
342
+ else
343
+ "Execute? [y/N/edit] "
344
+ end
345
+ end
346
+
347
+ def with_safety_guards(&block)
348
+ ConsoleAgent.configuration.safety_guards.wrap(&block)
349
+ end
350
+
351
+ # Check if an exception is or wraps a SafetyError (e.g. AR::StatementInvalid wrapping it)
352
+ def safety_error?(exception)
353
+ return true if exception.is_a?(ConsoleAgent::SafetyError)
354
+ return true if exception.message.include?("ConsoleAgent safe mode")
355
+ cause = exception.cause
356
+ while cause
357
+ return true if cause.is_a?(ConsoleAgent::SafetyError)
358
+ cause = cause.cause
359
+ end
360
+ false
361
+ end
362
+
363
+ def extract_safety_exception(exception)
364
+ return exception if exception.is_a?(ConsoleAgent::SafetyError)
365
+ cause = exception.cause
366
+ while cause
367
+ return cause if cause.is_a?(ConsoleAgent::SafetyError)
368
+ cause = cause.cause
369
+ end
370
+ nil
371
+ end
372
+
166
373
  MAX_DISPLAY_LINES = 10
167
374
  MAX_DISPLAY_CHARS = 2000
168
375
 
169
376
  def display_result(result)
170
- full = "=> #{result.inspect}"
171
- lines = full.lines
172
- total_lines = lines.length
173
- total_chars = full.length
174
-
175
- if total_lines <= MAX_DISPLAY_LINES && total_chars <= MAX_DISPLAY_CHARS
176
- $stdout.puts colorize(full, :green)
377
+ if @channel
378
+ @channel.display_result(result)
177
379
  else
178
- # Truncate by lines first, then by chars
179
- truncated = lines.first(MAX_DISPLAY_LINES).join
180
- truncated = truncated[0, MAX_DISPLAY_CHARS] if truncated.length > MAX_DISPLAY_CHARS
181
- $stdout.puts colorize(truncated, :green)
182
-
183
- omitted_lines = [total_lines - MAX_DISPLAY_LINES, 0].max
184
- omitted_chars = [total_chars - truncated.length, 0].max
185
- parts = []
186
- parts << "#{omitted_lines} lines" if omitted_lines > 0
187
- parts << "#{omitted_chars} chars" if omitted_chars > 0
188
- $stdout.puts colorize(" (omitting #{parts.join(', ')})", :yellow)
380
+ full = "=> #{result.inspect}"
381
+ lines = full.lines
382
+ total_lines = lines.length
383
+ total_chars = full.length
384
+
385
+ if total_lines <= MAX_DISPLAY_LINES && total_chars <= MAX_DISPLAY_CHARS
386
+ $stdout.puts colorize(full, :green)
387
+ else
388
+ truncated = lines.first(MAX_DISPLAY_LINES).join
389
+ truncated = truncated[0, MAX_DISPLAY_CHARS] if truncated.length > MAX_DISPLAY_CHARS
390
+ $stdout.puts colorize(truncated, :green)
391
+
392
+ omitted_lines = [total_lines - MAX_DISPLAY_LINES, 0].max
393
+ omitted_chars = [total_chars - truncated.length, 0].max
394
+ parts = []
395
+ parts << "#{omitted_lines} lines" if omitted_lines > 0
396
+ parts << "#{omitted_chars} chars" if omitted_chars > 0
397
+
398
+ @omitted_counter += 1
399
+ @omitted_outputs[@omitted_counter] = full
400
+ $stdout.puts colorize(" (omitting #{parts.join(', ')}) /expand #{@omitted_counter} to see all", :yellow)
401
+ end
189
402
  end
190
403
  end
191
404
 
@@ -30,8 +30,9 @@ module ConsoleAgent
30
30
 
31
31
  def build_connection(url, headers = {})
32
32
  Faraday.new(url: url) do |f|
33
- f.options.timeout = config.timeout
34
- f.options.open_timeout = config.timeout
33
+ t = config.respond_to?(:resolved_timeout) ? config.resolved_timeout : config.timeout
34
+ f.options.timeout = t
35
+ f.options.open_timeout = t
35
36
  f.headers.update(headers)
36
37
  f.headers['Content-Type'] = 'application/json'
37
38
  f.adapter Faraday.default_adapter
@@ -41,24 +42,27 @@ module ConsoleAgent
41
42
  def debug_request(url, body)
42
43
  return unless config.debug
43
44
 
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"
45
+ parsed = body.is_a?(String) ? (JSON.parse(body) rescue nil) : body
46
+ if parsed
47
+ # Support both symbol and string keys
48
+ model = parsed[:model] || parsed['model']
49
+ msgs = parsed[:messages] || parsed['messages']
50
+ sys = parsed[:system] || parsed['system']
51
+ tools = parsed[:tools] || parsed['tools']
52
+ $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"
53
+ else
54
+ $stderr.puts "\e[33m[debug] POST #{url}\e[0m"
55
+ end
51
56
  end
52
57
 
53
58
  def debug_response(body)
54
59
  return unless config.debug
55
60
 
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"
61
+ parsed = body.is_a?(String) ? (JSON.parse(body) rescue nil) : body
62
+ if parsed && parsed['usage']
63
+ u = parsed['usage']
64
+ $stderr.puts "\e[36m[debug] response: #{parsed['stop_reason']} | in: #{u['input_tokens']} out: #{u['output_tokens']}\e[0m"
65
+ end
62
66
  end
63
67
 
64
68
  def parse_response(response)
@@ -98,6 +102,10 @@ module ConsoleAgent
98
102
  when :openai
99
103
  require 'console_agent/providers/openai'
100
104
  OpenAI.new(config)
105
+ when :local
106
+ require 'console_agent/providers/openai'
107
+ require 'console_agent/providers/local'
108
+ Local.new(config)
101
109
  else
102
110
  raise ConfigurationError, "Unknown provider: #{config.provider}"
103
111
  end
@@ -0,0 +1,112 @@
1
+ module ConsoleAgent
2
+ module Providers
3
+ class Local < OpenAI
4
+ private
5
+
6
+ def call_api(messages, system_prompt: nil, tools: nil)
7
+ base_url = config.local_url
8
+
9
+ headers = { 'Content-Type' => 'application/json' }
10
+ api_key = config.local_api_key
11
+ if api_key && api_key != 'no-key' && !api_key.empty?
12
+ headers['Authorization'] = "Bearer #{api_key}"
13
+ end
14
+
15
+ conn = build_connection(base_url, headers)
16
+
17
+ formatted = []
18
+ formatted << { role: 'system', content: system_prompt } if system_prompt
19
+ formatted.concat(format_messages(messages))
20
+
21
+ body = {
22
+ model: config.resolved_model,
23
+ max_tokens: config.resolved_max_tokens,
24
+ temperature: config.temperature,
25
+ messages: formatted
26
+ }
27
+ body[:tools] = tools.to_openai_format if tools
28
+
29
+ estimated_input_tokens = estimate_tokens(formatted, system_prompt, tools)
30
+
31
+ json_body = JSON.generate(body)
32
+ debug_request("#{base_url}/v1/chat/completions", body)
33
+ response = conn.post('/v1/chat/completions', json_body)
34
+ debug_response(response.body)
35
+ data = parse_response(response)
36
+ usage = data['usage'] || {}
37
+
38
+ prompt_tokens = usage['prompt_tokens']
39
+ if prompt_tokens && estimated_input_tokens > 0 && prompt_tokens < estimated_input_tokens * 0.5
40
+ raise ProviderError,
41
+ "Context truncated by local server: sent ~#{estimated_input_tokens} estimated tokens " \
42
+ "but server only used #{prompt_tokens}. Increase the model's context window " \
43
+ "(e.g. num_ctx for Ollama) or reduce conversation length."
44
+ end
45
+
46
+ choice = (data['choices'] || []).first || {}
47
+ message = choice['message'] || {}
48
+ finish_reason = choice['finish_reason']
49
+
50
+ tool_calls = extract_tool_calls(message)
51
+
52
+ # Fallback: some local models (e.g. Ollama) emit tool calls as JSON
53
+ # in the content field instead of using the structured tool_calls format.
54
+ # Only match when the JSON "name" is a known tool name to avoid false positives.
55
+ if tool_calls.empty? && tools
56
+ tool_names = tools.to_openai_format.map { |t| t.dig('function', 'name') }.compact
57
+ text_calls = extract_tool_calls_from_text(message['content'], tool_names)
58
+ if text_calls.any?
59
+ tool_calls = text_calls
60
+ finish_reason = 'tool_calls'
61
+ message['content'] = ''
62
+ end
63
+ end
64
+
65
+ stop = finish_reason == 'tool_calls' ? :tool_use : :end_turn
66
+
67
+ ChatResult.new(
68
+ text: message['content'] || '',
69
+ input_tokens: usage['prompt_tokens'],
70
+ output_tokens: usage['completion_tokens'],
71
+ tool_calls: tool_calls,
72
+ stop_reason: stop
73
+ )
74
+ end
75
+
76
+ def estimate_tokens(messages, system_prompt, tools)
77
+ chars = system_prompt.to_s.length
78
+ messages.each { |m| chars += m[:content].to_s.length + (m[:tool_calls].to_s.length) }
79
+ chars += tools.to_openai_format.to_s.length if tools
80
+ chars / 4
81
+ end
82
+
83
+ # Parse tool calls emitted as JSON text in the content field.
84
+ # Only recognizes calls whose "name" matches a known tool name.
85
+ def extract_tool_calls_from_text(content, tool_names)
86
+ return [] if content.nil? || content.strip.empty?
87
+
88
+ text = content.strip
89
+ parsed = begin
90
+ JSON.parse(text)
91
+ rescue JSON::ParserError
92
+ match = text.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/)
93
+ match ? (JSON.parse(match[1]) rescue nil) : nil
94
+ end
95
+
96
+ return [] unless parsed
97
+
98
+ calls = parsed.is_a?(Array) ? parsed : [parsed]
99
+ calls.filter_map do |call|
100
+ next unless call.is_a?(Hash) && tool_names.include?(call['name'])
101
+ {
102
+ id: "local_#{SecureRandom.hex(4)}",
103
+ name: call['name'],
104
+ arguments: call['arguments'] || {}
105
+ }
106
+ end
107
+ rescue
108
+ []
109
+ end
110
+ end
111
+ end
112
+ end
@@ -2,6 +2,10 @@ require 'console_agent'
2
2
 
3
3
  module ConsoleAgent
4
4
  class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load File.expand_path('../tasks/console_agent.rake', __dir__)
7
+ end
8
+
5
9
  console do
6
10
  require 'console_agent/console_methods'
7
11