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.
@@ -43,11 +43,12 @@ 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
51
52
  @omitted_outputs = {}
52
53
  @omitted_counter = 0
53
54
  @output_store = {}
@@ -59,18 +60,27 @@ module ConsoleAgent
59
60
  match ? match[1].strip : ''
60
61
  end
61
62
 
63
+ # Matches any fenced code block (```anything ... ```)
64
+ ANY_CODE_FENCE_REGEX = /```\w*\s*\n.*?```/m
65
+
62
66
  def display_response(response)
63
67
  code = extract_code(response)
64
- explanation = response.gsub(CODE_REGEX, '').strip
65
-
66
- $stdout.puts
67
- $stdout.puts colorize(explanation, :cyan) unless explanation.empty?
68
+ explanation = response.gsub(ANY_CODE_FENCE_REGEX, '').strip
68
69
 
69
- unless code.empty?
70
+ if @channel
70
71
  $stdout.puts
71
- $stdout.puts colorize("# Generated code:", :yellow)
72
- $stdout.puts highlight_code(code)
72
+ @channel.display(explanation) unless explanation.empty?
73
+ @channel.display_code(code) unless code.empty?
74
+ else
73
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
74
84
  end
75
85
 
76
86
  code
@@ -80,29 +90,58 @@ module ConsoleAgent
80
90
  return nil if code.nil? || code.strip.empty?
81
91
 
82
92
  @last_error = nil
93
+ @last_safety_error = false
94
+ @last_safety_exception = nil
83
95
  captured_output = StringIO.new
84
96
  old_stdout = $stdout
85
97
  # Tee output: capture it and also print to the real stdout
86
98
  $stdout = TeeIO.new(old_stdout, captured_output)
87
99
 
88
- result = binding_context.eval(code, "(console_agent)", 1)
100
+ result = with_safety_guards do
101
+ binding_context.eval(code, "(console_agent)", 1)
102
+ end
89
103
 
90
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
+
91
111
  display_result(result)
92
112
 
93
113
  @last_output = captured_output.string
94
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
95
123
  rescue SyntaxError => e
96
124
  $stdout = old_stdout if old_stdout
97
125
  @last_error = "SyntaxError: #{e.message}"
98
- $stderr.puts colorize(@last_error, :red)
126
+ display_error(@last_error)
99
127
  @last_output = nil
100
128
  nil
101
129
  rescue => e
102
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
103
142
  @last_error = "#{e.class}: #{e.message}"
104
- $stderr.puts colorize("Error: #{@last_error}", :red)
105
- 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}") }
106
145
  @last_output = captured_output&.string
107
146
  nil
108
147
  end
@@ -138,24 +177,54 @@ module ConsoleAgent
138
177
 
139
178
  @last_cancelled = false
140
179
  @last_answer = nil
141
- $stdout.print colorize("Execute? [y/N/edit] ", :yellow)
142
- @on_prompt&.call
143
- 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
144
190
  @last_answer = answer
145
- echo_stdin(answer)
146
191
 
147
192
  loop do
148
193
  case answer
149
194
  when 'y', 'yes', 'a'
150
- 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)
151
212
  when 'e', 'edit'
152
- 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
153
218
  if edited && edited != code
154
219
  $stdout.puts colorize("# Edited code:", :yellow)
155
220
  $stdout.puts highlight_code(edited)
156
- $stdout.print colorize("Execute edited code? [y/N] ", :yellow)
157
- edit_answer = $stdin.gets.to_s.strip.downcase
158
- 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
159
228
  if edit_answer == 'y'
160
229
  return execute(edited)
161
230
  else
@@ -170,43 +239,166 @@ module ConsoleAgent
170
239
  @last_cancelled = true
171
240
  return nil
172
241
  else
173
- $stdout.print colorize("Execute? [y/N/edit] ", :yellow)
174
- @on_prompt&.call
175
- 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
176
250
  @last_answer = answer
177
- echo_stdin(answer)
178
251
  end
179
252
  end
180
253
  end
181
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
+
182
305
  private
183
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
+
184
373
  MAX_DISPLAY_LINES = 10
185
374
  MAX_DISPLAY_CHARS = 2000
186
375
 
187
376
  def display_result(result)
188
- full = "=> #{result.inspect}"
189
- lines = full.lines
190
- total_lines = lines.length
191
- total_chars = full.length
192
-
193
- if total_lines <= MAX_DISPLAY_LINES && total_chars <= MAX_DISPLAY_CHARS
194
- $stdout.puts colorize(full, :green)
377
+ if @channel
378
+ @channel.display_result(result)
195
379
  else
196
- # Truncate by lines first, then by chars
197
- truncated = lines.first(MAX_DISPLAY_LINES).join
198
- truncated = truncated[0, MAX_DISPLAY_CHARS] if truncated.length > MAX_DISPLAY_CHARS
199
- $stdout.puts colorize(truncated, :green)
200
-
201
- omitted_lines = [total_lines - MAX_DISPLAY_LINES, 0].max
202
- omitted_chars = [total_chars - truncated.length, 0].max
203
- parts = []
204
- parts << "#{omitted_lines} lines" if omitted_lines > 0
205
- parts << "#{omitted_chars} chars" if omitted_chars > 0
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)
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
210
402
  end
211
403
  end
212
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
@@ -101,6 +102,10 @@ module ConsoleAgent
101
102
  when :openai
102
103
  require 'console_agent/providers/openai'
103
104
  OpenAI.new(config)
105
+ when :local
106
+ require 'console_agent/providers/openai'
107
+ require 'console_agent/providers/local'
108
+ Local.new(config)
104
109
  else
105
110
  raise ConfigurationError, "Unknown provider: #{config.provider}"
106
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