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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +101 -1
- 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 +74 -5
- data/lib/console_agent/conversation_engine.rb +1122 -0
- data/lib/console_agent/executor.rb +239 -47
- data/lib/console_agent/providers/base.rb +7 -2
- data/lib/console_agent/providers/local.rb +112 -0
- data/lib/console_agent/railtie.rb +4 -0
- data/lib/console_agent/repl.rb +26 -1291
- 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 +473 -0
- data/lib/console_agent/tools/registry.rb +48 -16
- data/lib/console_agent/version.rb +1 -1
- data/lib/console_agent.rb +17 -3
- data/lib/generators/console_agent/templates/initializer.rb +34 -1
- data/lib/tasks/console_agent.rake +7 -0
- metadata +9 -1
|
@@ -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(
|
|
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
|
-
|
|
70
|
+
if @channel
|
|
70
71
|
$stdout.puts
|
|
71
|
-
|
|
72
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
e.backtrace.first(3).each { |line|
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|