console_agent 0.0.1 → 0.2.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/LICENSE +21 -0
- data/README.md +335 -0
- data/lib/console_agent/configuration.rb +61 -0
- data/lib/console_agent/console_methods.rb +131 -0
- data/lib/console_agent/context_builder.rb +229 -0
- data/lib/console_agent/executor.rb +193 -0
- data/lib/console_agent/providers/anthropic.rb +112 -0
- data/lib/console_agent/providers/base.rb +106 -0
- data/lib/console_agent/providers/openai.rb +114 -0
- data/lib/console_agent/railtie.rb +30 -0
- data/lib/console_agent/repl.rb +342 -0
- data/lib/console_agent/storage/base.rb +27 -0
- data/lib/console_agent/storage/file_storage.rb +63 -0
- data/lib/console_agent/tools/code_tools.rb +114 -0
- data/lib/console_agent/tools/memory_tools.rb +136 -0
- data/lib/console_agent/tools/model_tools.rb +95 -0
- data/lib/console_agent/tools/registry.rb +253 -0
- data/lib/console_agent/tools/schema_tools.rb +60 -0
- data/lib/console_agent/version.rb +3 -0
- data/lib/console_agent.rb +76 -1
- data/lib/generators/console_agent/install_generator.rb +26 -0
- data/lib/generators/console_agent/templates/initializer.rb +33 -0
- metadata +95 -3
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
module ConsoleAgent
|
|
2
|
+
class ContextBuilder
|
|
3
|
+
def initialize(config = ConsoleAgent.configuration)
|
|
4
|
+
@config = config
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def build
|
|
8
|
+
case @config.context_mode
|
|
9
|
+
when :smart
|
|
10
|
+
build_smart
|
|
11
|
+
else
|
|
12
|
+
build_full
|
|
13
|
+
end
|
|
14
|
+
rescue => e
|
|
15
|
+
ConsoleAgent.logger.warn("ConsoleAgent: context build error: #{e.message}")
|
|
16
|
+
system_instructions + "\n\n" + environment_context
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def build_full
|
|
20
|
+
parts = []
|
|
21
|
+
parts << system_instructions
|
|
22
|
+
parts << environment_context
|
|
23
|
+
parts << schema_context
|
|
24
|
+
parts << models_context
|
|
25
|
+
parts << routes_context
|
|
26
|
+
parts.compact.join("\n\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_smart
|
|
30
|
+
parts = []
|
|
31
|
+
parts << smart_system_instructions
|
|
32
|
+
parts << environment_context
|
|
33
|
+
parts << memory_context
|
|
34
|
+
parts.compact.join("\n\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def smart_system_instructions
|
|
40
|
+
<<~PROMPT.strip
|
|
41
|
+
You are a Ruby on Rails console assistant. The user is in a `rails console` session.
|
|
42
|
+
You help them query data, debug issues, and understand their application.
|
|
43
|
+
|
|
44
|
+
You have tools available to introspect the app's database schema, models, and source code.
|
|
45
|
+
Use them as needed to write accurate queries. For example, call list_tables to see what
|
|
46
|
+
tables exist, then describe_table to get column details for the ones you need.
|
|
47
|
+
|
|
48
|
+
You also have an ask_user tool to ask the console user clarifying questions. Use it when
|
|
49
|
+
you need specific information to write accurate code — such as which user they are, which
|
|
50
|
+
record to target, or what value to use.
|
|
51
|
+
|
|
52
|
+
You have memory tools to persist what you learn across sessions:
|
|
53
|
+
- save_memory: persist facts or procedures you learn about this codebase.
|
|
54
|
+
If a memory with the same name already exists, it will be updated in place.
|
|
55
|
+
- delete_memory: remove a memory by name
|
|
56
|
+
- recall_memories: search your saved memories for details
|
|
57
|
+
|
|
58
|
+
IMPORTANT: Check the Memories section below BEFORE answering. If a memory is relevant,
|
|
59
|
+
use recall_memories to get full details and apply that knowledge to your answer.
|
|
60
|
+
When you use a memory, mention it briefly (e.g. "Based on what I know about sharding...").
|
|
61
|
+
When you discover important patterns about this app, save them as memories.
|
|
62
|
+
|
|
63
|
+
RULES:
|
|
64
|
+
- Give ONE concise answer. Do not offer multiple alternatives or variations.
|
|
65
|
+
- Respond with a single ```ruby code block that directly answers the question.
|
|
66
|
+
- Include a brief one-line explanation before the code block.
|
|
67
|
+
- Use the app's actual model names, associations, and schema.
|
|
68
|
+
- Prefer ActiveRecord query interface over raw SQL.
|
|
69
|
+
- For destructive operations, add a comment warning.
|
|
70
|
+
- NEVER use placeholder values like YOUR_USER_ID or YOUR_EMAIL in code. If you need
|
|
71
|
+
a specific value from the user, call the ask_user tool to get it first.
|
|
72
|
+
- Keep code concise and idiomatic.
|
|
73
|
+
- Use tools to look up schema/model details rather than guessing column names.
|
|
74
|
+
PROMPT
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def system_instructions
|
|
78
|
+
<<~PROMPT.strip
|
|
79
|
+
You are a Ruby on Rails console assistant. The user is in a `rails console` session.
|
|
80
|
+
You help them query data, debug issues, and understand their application.
|
|
81
|
+
|
|
82
|
+
RULES:
|
|
83
|
+
- Give ONE concise answer. Do not offer multiple alternatives or variations.
|
|
84
|
+
- Respond with a single ```ruby code block that directly answers the question.
|
|
85
|
+
- Include a brief one-line explanation before the code block.
|
|
86
|
+
- Use the app's actual model names, associations, and schema.
|
|
87
|
+
- Prefer ActiveRecord query interface over raw SQL.
|
|
88
|
+
- For destructive operations, add a comment warning.
|
|
89
|
+
- If the request is ambiguous, ask a clarifying question instead of guessing.
|
|
90
|
+
- Keep code concise and idiomatic.
|
|
91
|
+
PROMPT
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def environment_context
|
|
95
|
+
lines = ["## Environment"]
|
|
96
|
+
lines << "- Ruby #{RUBY_VERSION}"
|
|
97
|
+
lines << "- Rails #{Rails.version}" if defined?(Rails) && Rails.respond_to?(:version)
|
|
98
|
+
|
|
99
|
+
if defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
|
|
100
|
+
adapter = ActiveRecord::Base.connection.adapter_name rescue 'unknown'
|
|
101
|
+
lines << "- Database adapter: #{adapter}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
if defined?(Bundler)
|
|
105
|
+
key_gems = %w[devise cancancan pundit sidekiq delayed_job resque
|
|
106
|
+
paperclip carrierwave activestorage shrine
|
|
107
|
+
pg mysql2 sqlite3 mongoid]
|
|
108
|
+
loaded = key_gems.select { |g| Gem.loaded_specs.key?(g) }
|
|
109
|
+
lines << "- Key gems: #{loaded.join(', ')}" unless loaded.empty?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
lines.join("\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def schema_context
|
|
116
|
+
return nil unless defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
|
|
117
|
+
|
|
118
|
+
conn = ActiveRecord::Base.connection
|
|
119
|
+
tables = conn.tables.sort
|
|
120
|
+
return nil if tables.empty?
|
|
121
|
+
|
|
122
|
+
lines = ["## Database Schema"]
|
|
123
|
+
tables.each do |table|
|
|
124
|
+
next if table == 'schema_migrations' || table == 'ar_internal_metadata'
|
|
125
|
+
|
|
126
|
+
cols = conn.columns(table).map { |c| "#{c.name}:#{c.type}" }
|
|
127
|
+
lines << "- #{table} (#{cols.join(', ')})"
|
|
128
|
+
end
|
|
129
|
+
lines.join("\n")
|
|
130
|
+
rescue => e
|
|
131
|
+
ConsoleAgent.logger.debug("ConsoleAgent: schema introspection failed: #{e.message}")
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def models_context
|
|
136
|
+
return nil unless defined?(ActiveRecord::Base)
|
|
137
|
+
|
|
138
|
+
eager_load_app!
|
|
139
|
+
base_class = defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
|
|
140
|
+
models = ObjectSpace.each_object(Class).select { |c|
|
|
141
|
+
c < base_class && !c.abstract_class? && c.name && !c.name.start_with?('HABTM_')
|
|
142
|
+
}.sort_by(&:name)
|
|
143
|
+
|
|
144
|
+
return nil if models.empty?
|
|
145
|
+
|
|
146
|
+
lines = ["## Models"]
|
|
147
|
+
models.each do |model|
|
|
148
|
+
parts = [model.name]
|
|
149
|
+
|
|
150
|
+
assocs = model.reflect_on_all_associations.map { |a| "#{a.macro} :#{a.name}" }
|
|
151
|
+
parts << " associations: #{assocs.join(', ')}" unless assocs.empty?
|
|
152
|
+
|
|
153
|
+
scopes = model.methods.grep(/^_scope_/).map { |m| m.to_s.sub('_scope_', '') } rescue []
|
|
154
|
+
if scopes.empty?
|
|
155
|
+
# Alternative: check singleton methods that return ActiveRecord::Relation
|
|
156
|
+
scope_names = (model.singleton_methods - base_class.singleton_methods).select { |m|
|
|
157
|
+
m != :all && !m.to_s.start_with?('_') && !m.to_s.start_with?('find')
|
|
158
|
+
}.first(10)
|
|
159
|
+
# We won't list these to avoid noise — scopes are hard to detect reliably
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
validations = model.validators.map { |v|
|
|
163
|
+
attrs = v.attributes.join(', ')
|
|
164
|
+
"#{v.class.name.demodulize.underscore.sub('_validator', '')} on #{attrs}"
|
|
165
|
+
}.uniq.first(5) rescue []
|
|
166
|
+
parts << " validations: #{validations.join('; ')}" unless validations.empty?
|
|
167
|
+
|
|
168
|
+
lines << "- #{parts.join("\n")}"
|
|
169
|
+
end
|
|
170
|
+
lines.join("\n")
|
|
171
|
+
rescue => e
|
|
172
|
+
ConsoleAgent.logger.debug("ConsoleAgent: model introspection failed: #{e.message}")
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def routes_context
|
|
177
|
+
return nil unless defined?(Rails) && Rails.respond_to?(:application)
|
|
178
|
+
|
|
179
|
+
routes = Rails.application.routes.routes
|
|
180
|
+
return nil if routes.empty?
|
|
181
|
+
|
|
182
|
+
lines = ["## Routes (summary)"]
|
|
183
|
+
count = 0
|
|
184
|
+
routes.each do |route|
|
|
185
|
+
next if route.internal?
|
|
186
|
+
path = route.path.spec.to_s.sub('(.:format)', '')
|
|
187
|
+
verb = route.verb.to_s
|
|
188
|
+
next if verb.empty?
|
|
189
|
+
action = route.defaults[:controller].to_s + '#' + route.defaults[:action].to_s
|
|
190
|
+
lines << "- #{verb} #{path} -> #{action}"
|
|
191
|
+
count += 1
|
|
192
|
+
break if count >= 50
|
|
193
|
+
end
|
|
194
|
+
lines << "- ... (#{routes.size - count} more routes)" if routes.size > count
|
|
195
|
+
|
|
196
|
+
lines.join("\n")
|
|
197
|
+
rescue => e
|
|
198
|
+
ConsoleAgent.logger.debug("ConsoleAgent: route introspection failed: #{e.message}")
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def memory_context
|
|
203
|
+
return nil unless @config.memories_enabled
|
|
204
|
+
|
|
205
|
+
require 'console_agent/tools/memory_tools'
|
|
206
|
+
summaries = Tools::MemoryTools.new.memory_summaries
|
|
207
|
+
return nil if summaries.nil? || summaries.empty?
|
|
208
|
+
|
|
209
|
+
lines = ["## Memories"]
|
|
210
|
+
lines.concat(summaries)
|
|
211
|
+
lines << ""
|
|
212
|
+
lines << "Call recall_memories to get details before answering. Do NOT guess from the name alone."
|
|
213
|
+
lines.join("\n")
|
|
214
|
+
rescue => e
|
|
215
|
+
ConsoleAgent.logger.debug("ConsoleAgent: memory context failed: #{e.message}")
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def eager_load_app!
|
|
220
|
+
return unless defined?(Rails) && Rails.respond_to?(:application)
|
|
221
|
+
|
|
222
|
+
if Rails.application.respond_to?(:eager_load!)
|
|
223
|
+
Rails.application.eager_load!
|
|
224
|
+
end
|
|
225
|
+
rescue => e
|
|
226
|
+
ConsoleAgent.logger.debug("ConsoleAgent: eager_load failed: #{e.message}")
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
require 'stringio'
|
|
2
|
+
|
|
3
|
+
module ConsoleAgent
|
|
4
|
+
# Writes to two IO streams simultaneously
|
|
5
|
+
class TeeIO
|
|
6
|
+
def initialize(primary, secondary)
|
|
7
|
+
@primary = primary
|
|
8
|
+
@secondary = secondary
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def write(str)
|
|
12
|
+
@primary.write(str)
|
|
13
|
+
@secondary.write(str)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def puts(*args)
|
|
17
|
+
@primary.puts(*args)
|
|
18
|
+
# Capture what puts would output
|
|
19
|
+
args.each { |a| @secondary.write("#{a}\n") }
|
|
20
|
+
@secondary.write("\n") if args.empty?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def print(*args)
|
|
24
|
+
@primary.print(*args)
|
|
25
|
+
args.each { |a| @secondary.write(a.to_s) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def flush
|
|
29
|
+
@primary.flush if @primary.respond_to?(:flush)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def respond_to_missing?(method, include_private = false)
|
|
33
|
+
@primary.respond_to?(method, include_private) || super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def method_missing(method, *args, &block)
|
|
37
|
+
@primary.send(method, *args, &block)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class Executor
|
|
42
|
+
CODE_REGEX = /```ruby\s*\n(.*?)```/m
|
|
43
|
+
|
|
44
|
+
attr_reader :binding_context
|
|
45
|
+
|
|
46
|
+
def initialize(binding_context)
|
|
47
|
+
@binding_context = binding_context
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def extract_code(response)
|
|
51
|
+
match = response.match(CODE_REGEX)
|
|
52
|
+
match ? match[1].strip : ''
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def display_response(response)
|
|
56
|
+
code = extract_code(response)
|
|
57
|
+
explanation = response.gsub(CODE_REGEX, '').strip
|
|
58
|
+
|
|
59
|
+
$stdout.puts
|
|
60
|
+
$stdout.puts colorize(explanation, :cyan) unless explanation.empty?
|
|
61
|
+
|
|
62
|
+
unless code.empty?
|
|
63
|
+
$stdout.puts
|
|
64
|
+
$stdout.puts colorize("# Generated code:", :yellow)
|
|
65
|
+
$stdout.puts highlight_code(code)
|
|
66
|
+
$stdout.puts
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
code
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def execute(code)
|
|
73
|
+
return nil if code.nil? || code.strip.empty?
|
|
74
|
+
|
|
75
|
+
captured_output = StringIO.new
|
|
76
|
+
old_stdout = $stdout
|
|
77
|
+
# Tee output: capture it and also print to the real stdout
|
|
78
|
+
$stdout = TeeIO.new(old_stdout, captured_output)
|
|
79
|
+
|
|
80
|
+
result = binding_context.eval(code, "(console_agent)", 1)
|
|
81
|
+
|
|
82
|
+
$stdout = old_stdout
|
|
83
|
+
$stdout.puts colorize("=> #{result.inspect}", :green)
|
|
84
|
+
|
|
85
|
+
@last_output = captured_output.string
|
|
86
|
+
result
|
|
87
|
+
rescue SyntaxError => e
|
|
88
|
+
$stdout = old_stdout if old_stdout
|
|
89
|
+
$stderr.puts colorize("SyntaxError: #{e.message}", :red)
|
|
90
|
+
@last_output = nil
|
|
91
|
+
nil
|
|
92
|
+
rescue => e
|
|
93
|
+
$stdout = old_stdout if old_stdout
|
|
94
|
+
$stderr.puts colorize("Error: #{e.class}: #{e.message}", :red)
|
|
95
|
+
e.backtrace.first(3).each { |line| $stderr.puts colorize(" #{line}", :red) }
|
|
96
|
+
@last_output = captured_output&.string
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def last_output
|
|
101
|
+
@last_output
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def last_cancelled?
|
|
105
|
+
@last_cancelled
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def confirm_and_execute(code)
|
|
109
|
+
return nil if code.nil? || code.strip.empty?
|
|
110
|
+
|
|
111
|
+
@last_cancelled = false
|
|
112
|
+
$stdout.print colorize("Execute? [y/N/edit] ", :yellow)
|
|
113
|
+
answer = $stdin.gets.to_s.strip.downcase
|
|
114
|
+
|
|
115
|
+
case answer
|
|
116
|
+
when 'y', 'yes'
|
|
117
|
+
execute(code)
|
|
118
|
+
when 'e', 'edit'
|
|
119
|
+
edited = open_in_editor(code)
|
|
120
|
+
if edited && edited != code
|
|
121
|
+
$stdout.puts colorize("# Edited code:", :yellow)
|
|
122
|
+
$stdout.puts highlight_code(edited)
|
|
123
|
+
$stdout.print colorize("Execute edited code? [y/N] ", :yellow)
|
|
124
|
+
if $stdin.gets.to_s.strip.downcase == 'y'
|
|
125
|
+
execute(edited)
|
|
126
|
+
else
|
|
127
|
+
$stdout.puts colorize("Cancelled.", :yellow)
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
else
|
|
131
|
+
execute(code)
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
$stdout.puts colorize("Cancelled.", :yellow)
|
|
135
|
+
@last_cancelled = true
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def open_in_editor(code)
|
|
143
|
+
require 'tempfile'
|
|
144
|
+
editor = ENV['EDITOR'] || 'vi'
|
|
145
|
+
tmpfile = Tempfile.new(['console_agent', '.rb'])
|
|
146
|
+
tmpfile.write(code)
|
|
147
|
+
tmpfile.flush
|
|
148
|
+
|
|
149
|
+
system("#{editor} #{tmpfile.path}")
|
|
150
|
+
File.read(tmpfile.path)
|
|
151
|
+
rescue => e
|
|
152
|
+
$stderr.puts colorize("Editor error: #{e.message}", :red)
|
|
153
|
+
code
|
|
154
|
+
ensure
|
|
155
|
+
tmpfile.close! if tmpfile
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def highlight_code(code)
|
|
159
|
+
if coderay_available?
|
|
160
|
+
CodeRay.scan(code, :ruby).terminal
|
|
161
|
+
else
|
|
162
|
+
colorize(code, :white)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def coderay_available?
|
|
167
|
+
return @coderay_available unless @coderay_available.nil?
|
|
168
|
+
@coderay_available = begin
|
|
169
|
+
require 'coderay'
|
|
170
|
+
true
|
|
171
|
+
rescue LoadError
|
|
172
|
+
false
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
COLORS = {
|
|
177
|
+
red: "\e[31m",
|
|
178
|
+
green: "\e[32m",
|
|
179
|
+
yellow: "\e[33m",
|
|
180
|
+
cyan: "\e[36m",
|
|
181
|
+
white: "\e[37m",
|
|
182
|
+
reset: "\e[0m"
|
|
183
|
+
}.freeze
|
|
184
|
+
|
|
185
|
+
def colorize(text, color)
|
|
186
|
+
if $stdout.respond_to?(:tty?) && $stdout.tty?
|
|
187
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
188
|
+
else
|
|
189
|
+
text
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module ConsoleAgent
|
|
2
|
+
module Providers
|
|
3
|
+
class Anthropic < Base
|
|
4
|
+
API_URL = 'https://api.anthropic.com'.freeze
|
|
5
|
+
|
|
6
|
+
def chat(messages, system_prompt: nil)
|
|
7
|
+
result = call_api(messages, system_prompt: system_prompt)
|
|
8
|
+
result
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def chat_with_tools(messages, tools:, system_prompt: nil)
|
|
12
|
+
call_api(messages, system_prompt: system_prompt, tools: tools)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def format_assistant_message(result)
|
|
16
|
+
# Rebuild the assistant content blocks from the raw response
|
|
17
|
+
content_blocks = []
|
|
18
|
+
content_blocks << { 'type' => 'text', 'text' => result.text } if result.text && !result.text.empty?
|
|
19
|
+
(result.tool_calls || []).each do |tc|
|
|
20
|
+
content_blocks << {
|
|
21
|
+
'type' => 'tool_use',
|
|
22
|
+
'id' => tc[:id],
|
|
23
|
+
'name' => tc[:name],
|
|
24
|
+
'input' => tc[:arguments]
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
{ role: 'assistant', content: content_blocks }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def format_tool_result(tool_call_id, result_string)
|
|
31
|
+
{
|
|
32
|
+
role: 'user',
|
|
33
|
+
content: [
|
|
34
|
+
{
|
|
35
|
+
'type' => 'tool_result',
|
|
36
|
+
'tool_use_id' => tool_call_id,
|
|
37
|
+
'content' => result_string.to_s
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def call_api(messages, system_prompt: nil, tools: nil)
|
|
46
|
+
conn = build_connection(API_URL, {
|
|
47
|
+
'x-api-key' => config.resolved_api_key,
|
|
48
|
+
'anthropic-version' => '2023-06-01'
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
body = {
|
|
52
|
+
model: config.resolved_model,
|
|
53
|
+
max_tokens: config.max_tokens,
|
|
54
|
+
temperature: config.temperature,
|
|
55
|
+
messages: format_messages(messages)
|
|
56
|
+
}
|
|
57
|
+
body[:system] = system_prompt if system_prompt
|
|
58
|
+
body[:tools] = tools.to_anthropic_format if tools
|
|
59
|
+
|
|
60
|
+
json_body = JSON.generate(body)
|
|
61
|
+
debug_request("#{API_URL}/v1/messages", body)
|
|
62
|
+
response = conn.post('/v1/messages', json_body)
|
|
63
|
+
debug_response(response.body)
|
|
64
|
+
data = parse_response(response)
|
|
65
|
+
usage = data['usage'] || {}
|
|
66
|
+
|
|
67
|
+
tool_calls = extract_tool_calls(data)
|
|
68
|
+
stop = data['stop_reason'] == 'tool_use' ? :tool_use : :end_turn
|
|
69
|
+
|
|
70
|
+
ChatResult.new(
|
|
71
|
+
text: extract_text(data),
|
|
72
|
+
input_tokens: usage['input_tokens'],
|
|
73
|
+
output_tokens: usage['output_tokens'],
|
|
74
|
+
tool_calls: tool_calls,
|
|
75
|
+
stop_reason: stop
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def format_messages(messages)
|
|
80
|
+
messages.map do |msg|
|
|
81
|
+
if msg[:content].is_a?(Array)
|
|
82
|
+
{ role: msg[:role].to_s, content: msg[:content] }
|
|
83
|
+
else
|
|
84
|
+
{ role: msg[:role].to_s, content: msg[:content].to_s }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def extract_text(data)
|
|
90
|
+
content = data['content']
|
|
91
|
+
return '' unless content.is_a?(Array)
|
|
92
|
+
|
|
93
|
+
content.select { |c| c['type'] == 'text' }
|
|
94
|
+
.map { |c| c['text'] }
|
|
95
|
+
.join("\n")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def extract_tool_calls(data)
|
|
99
|
+
content = data['content']
|
|
100
|
+
return [] unless content.is_a?(Array)
|
|
101
|
+
|
|
102
|
+
content.select { |c| c['type'] == 'tool_use' }.map do |c|
|
|
103
|
+
{
|
|
104
|
+
id: c['id'],
|
|
105
|
+
name: c['name'],
|
|
106
|
+
arguments: c['input'] || {}
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
module ConsoleAgent
|
|
5
|
+
module Providers
|
|
6
|
+
class Base
|
|
7
|
+
attr_reader :config
|
|
8
|
+
|
|
9
|
+
def initialize(config = ConsoleAgent.configuration)
|
|
10
|
+
@config = config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def chat(messages, system_prompt: nil)
|
|
14
|
+
raise NotImplementedError, "#{self.class}#chat must be implemented"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def chat_with_tools(messages, tools:, system_prompt: nil)
|
|
18
|
+
raise NotImplementedError, "#{self.class}#chat_with_tools must be implemented"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def format_assistant_message(_result)
|
|
22
|
+
raise NotImplementedError, "#{self.class}#format_assistant_message must be implemented"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def format_tool_result(_tool_call_id, _result_string)
|
|
26
|
+
raise NotImplementedError, "#{self.class}#format_tool_result must be implemented"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def build_connection(url, headers = {})
|
|
32
|
+
Faraday.new(url: url) do |f|
|
|
33
|
+
f.options.timeout = config.timeout
|
|
34
|
+
f.options.open_timeout = config.timeout
|
|
35
|
+
f.headers.update(headers)
|
|
36
|
+
f.headers['Content-Type'] = 'application/json'
|
|
37
|
+
f.adapter Faraday.default_adapter
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def debug_request(url, body)
|
|
42
|
+
return unless config.debug
|
|
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"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def debug_response(body)
|
|
54
|
+
return unless config.debug
|
|
55
|
+
|
|
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"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse_response(response)
|
|
65
|
+
unless response.success?
|
|
66
|
+
body = begin
|
|
67
|
+
JSON.parse(response.body)
|
|
68
|
+
rescue
|
|
69
|
+
{ 'error' => response.body }
|
|
70
|
+
end
|
|
71
|
+
error_msg = body.dig('error', 'message') || body['error'] || response.body
|
|
72
|
+
raise ProviderError, "API error (#{response.status}): #{error_msg}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
JSON.parse(response.body)
|
|
76
|
+
rescue JSON::ParserError => e
|
|
77
|
+
raise ProviderError, "Failed to parse response: #{e.message}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class ProviderError < StandardError; end
|
|
82
|
+
|
|
83
|
+
ChatResult = Struct.new(:text, :input_tokens, :output_tokens, :tool_calls, :stop_reason, keyword_init: true) do
|
|
84
|
+
def total_tokens
|
|
85
|
+
(input_tokens || 0) + (output_tokens || 0)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def tool_use?
|
|
89
|
+
stop_reason == :tool_use && tool_calls && !tool_calls.empty?
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.build(config = ConsoleAgent.configuration)
|
|
94
|
+
case config.provider
|
|
95
|
+
when :anthropic
|
|
96
|
+
require 'console_agent/providers/anthropic'
|
|
97
|
+
Anthropic.new(config)
|
|
98
|
+
when :openai
|
|
99
|
+
require 'console_agent/providers/openai'
|
|
100
|
+
OpenAI.new(config)
|
|
101
|
+
else
|
|
102
|
+
raise ConfigurationError, "Unknown provider: #{config.provider}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|