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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +104 -2
- data/app/helpers/console_agent/sessions_helper.rb +14 -0
- data/app/models/console_agent/session.rb +1 -1
- data/app/views/console_agent/sessions/index.html.erb +4 -4
- data/app/views/console_agent/sessions/show.html.erb +16 -6
- data/app/views/layouts/console_agent/application.html.erb +1 -0
- data/lib/console_agent/channel/base.rb +23 -0
- data/lib/console_agent/channel/console.rb +457 -0
- data/lib/console_agent/channel/slack.rb +182 -0
- data/lib/console_agent/configuration.rb +73 -5
- data/lib/console_agent/conversation_engine.rb +1122 -0
- data/lib/console_agent/executor.rb +257 -44
- data/lib/console_agent/providers/base.rb +23 -15
- data/lib/console_agent/providers/local.rb +112 -0
- data/lib/console_agent/railtie.rb +4 -0
- data/lib/console_agent/repl.rb +27 -1128
- data/lib/console_agent/safety_guards.rb +207 -0
- data/lib/console_agent/session_logger.rb +14 -3
- data/lib/console_agent/slack_bot.rb +465 -0
- data/lib/console_agent/tools/registry.rb +66 -16
- data/lib/console_agent/version.rb +1 -1
- data/lib/console_agent.rb +17 -3
- data/lib/generators/console_agent/templates/initializer.rb +30 -1
- data/lib/tasks/console_agent.rake +7 -0
- metadata +9 -1
|
@@ -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(
|
|
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
|
-
|
|
70
|
+
if @channel
|
|
66
71
|
$stdout.puts
|
|
67
|
-
|
|
68
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
e.backtrace.first(3).each { |line|
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
34
|
-
f.options.
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
57
|
-
parsed
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|