active_agent_rails 0.1.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 +7 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +151 -0
- data/Rakefile +12 -0
- data/app/controllers/active_agent/chats_controller.rb +84 -0
- data/app/helpers/active_agent/chat_helper.rb +427 -0
- data/config/routes.rb +6 -0
- data/lib/active_agent/base.rb +121 -0
- data/lib/active_agent/configuration.rb +30 -0
- data/lib/active_agent/engine.rb +16 -0
- data/lib/active_agent/memory/active_record.rb +68 -0
- data/lib/active_agent/memory/base.rb +42 -0
- data/lib/active_agent/memory/in_memory.rb +29 -0
- data/lib/active_agent/provider.rb +90 -0
- data/lib/active_agent/providers/anthropic.rb +208 -0
- data/lib/active_agent/providers/gemini.rb +169 -0
- data/lib/active_agent/providers/openai.rb +178 -0
- data/lib/active_agent/tool.rb +37 -0
- data/lib/active_agent/version.rb +5 -0
- data/lib/active_agent.rb +25 -0
- data/lib/active_agent_rails.rb +3 -0
- data/lib/generators/active_agent/agent/agent_generator.rb +15 -0
- data/lib/generators/active_agent/agent/templates/agent.rb.erb +22 -0
- data/lib/generators/active_agent/install/install_generator.rb +31 -0
- data/lib/generators/active_agent/install/templates/active_agent.rb +15 -0
- data/lib/generators/active_agent/install/templates/create_active_agent_messages.rb +18 -0
- metadata +144 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Memory
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :conversation_id
|
|
7
|
+
|
|
8
|
+
def initialize(conversation_id:)
|
|
9
|
+
@conversation_id = conversation_id
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Returns array of message hashes:
|
|
13
|
+
# [ { role: 'user', content: 'hello' }, ... ]
|
|
14
|
+
def messages
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Adds a message to memory
|
|
19
|
+
def add_message(role:, content:, **options)
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Clears the memory for this conversation
|
|
24
|
+
def clear
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.for(store_name, conversation_id)
|
|
30
|
+
case store_name.to_sym
|
|
31
|
+
when :in_memory
|
|
32
|
+
require_relative "in_memory"
|
|
33
|
+
InMemory.new(conversation_id: conversation_id)
|
|
34
|
+
when :active_record
|
|
35
|
+
require_relative "active_record"
|
|
36
|
+
ActiveRecord.new(conversation_id: conversation_id)
|
|
37
|
+
else
|
|
38
|
+
raise ArgumentError, "Unknown memory store: #{store_name}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module ActiveAgent
|
|
6
|
+
module Memory
|
|
7
|
+
class InMemory < Base
|
|
8
|
+
@store = Hash.new { |h, k| h[k] = [] }
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
attr_reader :store
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def messages
|
|
15
|
+
self.class.store[conversation_id]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add_message(role:, content:, **options)
|
|
19
|
+
message = { role: role, content: content }.merge(options)
|
|
20
|
+
self.class.store[conversation_id] << message
|
|
21
|
+
message
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def clear
|
|
25
|
+
self.class.store[conversation_id] = []
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Provider
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :api_key, :model
|
|
7
|
+
|
|
8
|
+
def initialize(api_key:, model:)
|
|
9
|
+
provider_name = self.class.name.split("::").last
|
|
10
|
+
if api_key.to_s.strip.empty?
|
|
11
|
+
raise "ActiveAgent Error: API key for #{provider_name} is not configured. Please set it in config/initializers/active_agent.rb or via environment variables."
|
|
12
|
+
end
|
|
13
|
+
@api_key = api_key
|
|
14
|
+
@model = model
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Abstract methods to be implemented by adapters
|
|
18
|
+
def chat(messages, tools: [], &block)
|
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #chat"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def format_tools(tools)
|
|
23
|
+
raise NotImplementedError, "#{self.class} must implement #format_tools"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# Helper for making HTTP requests
|
|
29
|
+
def post_request(url, headers, body, &block)
|
|
30
|
+
require "net/http"
|
|
31
|
+
require "uri"
|
|
32
|
+
require "json"
|
|
33
|
+
|
|
34
|
+
uri = URI.parse(url)
|
|
35
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
36
|
+
http.use_ssl = (uri.scheme == "https")
|
|
37
|
+
http.read_timeout = 60 # Allow time for tool calling and generation
|
|
38
|
+
|
|
39
|
+
request = Net::HTTP::Post.new(uri.request_uri, headers)
|
|
40
|
+
request.body = body.to_json
|
|
41
|
+
|
|
42
|
+
if block_given?
|
|
43
|
+
http.request(request) do |response|
|
|
44
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
45
|
+
ActiveAgent.logger.error("ActiveAgent API Error: #{response.code} #{response.body}")
|
|
46
|
+
raise "API Error: #{response.code}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
buffer = ""
|
|
50
|
+
response.read_body do |chunk|
|
|
51
|
+
buffer += chunk
|
|
52
|
+
while (line_index = buffer.index("\n"))
|
|
53
|
+
line = buffer[0...line_index].strip
|
|
54
|
+
buffer = buffer[(line_index + 1)..-1]
|
|
55
|
+
next if line.empty?
|
|
56
|
+
yield(line)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
nil # return nil when streaming
|
|
61
|
+
else
|
|
62
|
+
response = http.request(request)
|
|
63
|
+
|
|
64
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
65
|
+
ActiveAgent.logger.error("ActiveAgent API Error: #{response.code} #{response.body}")
|
|
66
|
+
raise "API Error: #{response.code} - #{response.body}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
JSON.parse(response.body)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.for(provider_name, model)
|
|
75
|
+
case provider_name.to_sym
|
|
76
|
+
when :gemini
|
|
77
|
+
require_relative "providers/gemini"
|
|
78
|
+
Gemini.new(api_key: ActiveAgent.configuration.gemini_api_key, model: model)
|
|
79
|
+
when :openai
|
|
80
|
+
require_relative "providers/openai"
|
|
81
|
+
OpenAI.new(api_key: ActiveAgent.configuration.openai_api_key, model: model)
|
|
82
|
+
when :anthropic
|
|
83
|
+
require_relative "providers/anthropic"
|
|
84
|
+
Anthropic.new(api_key: ActiveAgent.configuration.anthropic_api_key, model: model)
|
|
85
|
+
else
|
|
86
|
+
raise ArgumentError, "Unknown provider: #{provider_name}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../provider"
|
|
4
|
+
|
|
5
|
+
module ActiveAgent
|
|
6
|
+
module Provider
|
|
7
|
+
class Anthropic < Base
|
|
8
|
+
def chat(messages, tools: [], &block)
|
|
9
|
+
api_model = model || "claude-3-5-sonnet-latest"
|
|
10
|
+
url = "https://api.anthropic.com/v1/messages"
|
|
11
|
+
|
|
12
|
+
system_message = messages.find { |m| m[:role].to_s == "system" }
|
|
13
|
+
history_messages = messages.reject { |m| m[:role].to_s == "system" }
|
|
14
|
+
|
|
15
|
+
body = {
|
|
16
|
+
model: api_model,
|
|
17
|
+
max_tokens: 4096,
|
|
18
|
+
messages: format_messages(history_messages)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if system_message && !system_message[:content].to_s.empty?
|
|
22
|
+
body[:system] = system_message[:content]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if tools.any?
|
|
26
|
+
body[:tools] = format_tools(tools)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
headers = {
|
|
30
|
+
"Content-Type" => "application/json",
|
|
31
|
+
"x-api-key" => api_key,
|
|
32
|
+
"anthropic-version" => "2023-06-01"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if block_given?
|
|
36
|
+
body[:stream] = true
|
|
37
|
+
text_accumulator = ""
|
|
38
|
+
tool_calls_accumulator = {}
|
|
39
|
+
|
|
40
|
+
post_request(url, headers, body) do |line|
|
|
41
|
+
next unless line.start_with?("data:")
|
|
42
|
+
json_str = line.sub("data:", "").strip
|
|
43
|
+
next if json_str.empty?
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
event_data = JSON.parse(json_str)
|
|
47
|
+
case event_data["type"]
|
|
48
|
+
when "content_block_start"
|
|
49
|
+
index = event_data["index"]
|
|
50
|
+
block_info = event_data["content_block"]
|
|
51
|
+
if block_info["type"] == "tool_use"
|
|
52
|
+
tool_calls_accumulator[index] = {
|
|
53
|
+
id: block_info["id"],
|
|
54
|
+
name: block_info["name"],
|
|
55
|
+
partial_json: ""
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
when "content_block_delta"
|
|
59
|
+
index = event_data["index"]
|
|
60
|
+
delta = event_data["delta"]
|
|
61
|
+
|
|
62
|
+
if delta["type"] == "text_delta"
|
|
63
|
+
text_accumulator += delta["text"]
|
|
64
|
+
yield(delta["text"])
|
|
65
|
+
elsif delta["type"] == "input_json_delta"
|
|
66
|
+
tool_calls_accumulator[index][:partial_json] += delta["partial_json"] if tool_calls_accumulator[index]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
rescue JSON::ParserError
|
|
70
|
+
# Ignore json parse errors for incomplete lines
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
tool_calls = tool_calls_accumulator.values.map do |tc|
|
|
75
|
+
args = {}
|
|
76
|
+
begin
|
|
77
|
+
args = JSON.parse(tc[:partial_json], symbolize_names: true) unless tc[:partial_json].empty?
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
ActiveAgent.logger.warn("Anthropic could not parse streaming tool input JSON: #{tc[:partial_json]}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
id: tc[:id],
|
|
84
|
+
name: tc[:name],
|
|
85
|
+
args: args
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result = { role: "assistant" }
|
|
90
|
+
result[:content] = text_accumulator unless text_accumulator.empty?
|
|
91
|
+
result[:tool_calls] = tool_calls if tool_calls.any?
|
|
92
|
+
result
|
|
93
|
+
else
|
|
94
|
+
response_json = post_request(url, headers, body)
|
|
95
|
+
parse_response(response_json)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def format_tools(tools)
|
|
100
|
+
tools.map do |tool|
|
|
101
|
+
properties = {}
|
|
102
|
+
tool.parameters.each do |name, info|
|
|
103
|
+
properties[name] = {
|
|
104
|
+
type: Tool.map_type(info[:type], uppercase: false),
|
|
105
|
+
description: info[:description]
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
name: tool.name,
|
|
111
|
+
description: tool.description,
|
|
112
|
+
input_schema: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: properties,
|
|
115
|
+
required: tool.required_parameters
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# Translates generic messages into alternating user/assistant messages for Anthropic
|
|
124
|
+
def format_messages(messages)
|
|
125
|
+
formatted_list = []
|
|
126
|
+
|
|
127
|
+
messages.each do |msg|
|
|
128
|
+
role = msg[:role].to_s
|
|
129
|
+
|
|
130
|
+
if role == "tool"
|
|
131
|
+
# Anthropic expects tool results as 'user' role with a 'tool_result' block.
|
|
132
|
+
block = {
|
|
133
|
+
type: "tool_result",
|
|
134
|
+
tool_use_id: msg[:tool_call_id] || msg[:name],
|
|
135
|
+
content: msg[:content].to_s
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# If the last message is already user, merge this tool result block into it
|
|
139
|
+
if formatted_list.last && formatted_list.last[:role] == "user"
|
|
140
|
+
# Ensure the content is an array
|
|
141
|
+
last_content = formatted_list.last[:content]
|
|
142
|
+
if last_content.is_a?(String)
|
|
143
|
+
formatted_list.last[:content] = [{ type: "text", text: last_content }, block]
|
|
144
|
+
else
|
|
145
|
+
formatted_list.last[:content] << block
|
|
146
|
+
end
|
|
147
|
+
else
|
|
148
|
+
formatted_list << { role: "user", content: [block] }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
elsif role == "assistant"
|
|
152
|
+
content_blocks = []
|
|
153
|
+
content_blocks << { type: "text", text: msg[:content] } if msg[:content]
|
|
154
|
+
|
|
155
|
+
if msg[:tool_calls]&.any?
|
|
156
|
+
msg[:tool_calls].each do |tc|
|
|
157
|
+
content_blocks << {
|
|
158
|
+
type: "tool_use",
|
|
159
|
+
id: tc[:id] || tc[:name],
|
|
160
|
+
name: tc[:name],
|
|
161
|
+
input: tc[:args]
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
formatted_list << { role: "assistant", content: content_blocks }
|
|
167
|
+
|
|
168
|
+
elsif role == "user"
|
|
169
|
+
# Standard user message
|
|
170
|
+
formatted_list << { role: "user", content: msg[:content].to_s }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
formatted_list
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def parse_response(response)
|
|
178
|
+
result = { role: "assistant" }
|
|
179
|
+
content_blocks = response["content"] || []
|
|
180
|
+
|
|
181
|
+
text = ""
|
|
182
|
+
tool_calls = []
|
|
183
|
+
|
|
184
|
+
content_blocks.each do |block|
|
|
185
|
+
case block["type"]
|
|
186
|
+
when "text"
|
|
187
|
+
text += block["text"]
|
|
188
|
+
when "tool_use"
|
|
189
|
+
# Convert input keys to symbols
|
|
190
|
+
args = {}
|
|
191
|
+
if block["input"]
|
|
192
|
+
block["input"].each { |k, v| args[k.to_sym] = v }
|
|
193
|
+
end
|
|
194
|
+
tool_calls << {
|
|
195
|
+
id: block["id"],
|
|
196
|
+
name: block["name"],
|
|
197
|
+
args: args
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
result[:content] = text unless text.empty?
|
|
203
|
+
result[:tool_calls] = tool_calls if tool_calls.any?
|
|
204
|
+
result
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../provider"
|
|
4
|
+
|
|
5
|
+
module ActiveAgent
|
|
6
|
+
module Provider
|
|
7
|
+
class Gemini < Base
|
|
8
|
+
def chat(messages, tools: [], &block)
|
|
9
|
+
api_model = model || "gemini-2.5-flash"
|
|
10
|
+
headers = { "Content-Type" => "application/json" }
|
|
11
|
+
|
|
12
|
+
system_message = messages.find { |m| m[:role].to_s == "system" }
|
|
13
|
+
history_messages = messages.reject { |m| m[:role].to_s == "system" }
|
|
14
|
+
|
|
15
|
+
body = {
|
|
16
|
+
contents: format_messages(history_messages)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if system_message && !system_message[:content].to_s.empty?
|
|
20
|
+
body[:systemInstruction] = {
|
|
21
|
+
parts: [{ text: system_message[:content] }]
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if tools.any?
|
|
26
|
+
body[:tools] = [{ functionDeclarations: format_tools(tools) }]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if block_given?
|
|
30
|
+
url = "https://generativelanguage.googleapis.com/v1beta/models/#{api_model}:streamGenerateContent?key=#{api_key}&alt=sse"
|
|
31
|
+
text_accumulator = ""
|
|
32
|
+
tool_calls = []
|
|
33
|
+
|
|
34
|
+
post_request(url, headers, body) do |line|
|
|
35
|
+
next unless line.start_with?("data:")
|
|
36
|
+
json_str = line.sub("data:", "").strip
|
|
37
|
+
next if json_str.empty?
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
chunk = JSON.parse(json_str)
|
|
41
|
+
candidate = chunk.dig("candidates", 0)
|
|
42
|
+
next unless candidate
|
|
43
|
+
|
|
44
|
+
parts = candidate.dig("content", "parts") || []
|
|
45
|
+
parts.each do |part|
|
|
46
|
+
if part["text"]
|
|
47
|
+
text_accumulator += part["text"]
|
|
48
|
+
yield(part["text"])
|
|
49
|
+
elsif part["functionCall"]
|
|
50
|
+
fc = part["functionCall"]
|
|
51
|
+
args = {}
|
|
52
|
+
if fc["args"]
|
|
53
|
+
fc["args"].each { |k, v| args[k.to_sym] = v }
|
|
54
|
+
end
|
|
55
|
+
tool_calls << {
|
|
56
|
+
id: fc["name"],
|
|
57
|
+
name: fc["name"],
|
|
58
|
+
args: args
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
rescue JSON::ParserError
|
|
63
|
+
# Ignore json parse errors for incomplete lines
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
result = { role: "assistant" }
|
|
68
|
+
result[:content] = text_accumulator unless text_accumulator.empty?
|
|
69
|
+
result[:tool_calls] = tool_calls if tool_calls.any?
|
|
70
|
+
result
|
|
71
|
+
else
|
|
72
|
+
url = "https://generativelanguage.googleapis.com/v1beta/models/#{api_model}:generateContent?key=#{api_key}"
|
|
73
|
+
response_json = post_request(url, headers, body)
|
|
74
|
+
parse_response(response_json)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def format_tools(tools)
|
|
79
|
+
tools.map do |tool|
|
|
80
|
+
properties = {}
|
|
81
|
+
tool.parameters.each do |name, info|
|
|
82
|
+
properties[name] = {
|
|
83
|
+
type: Tool.map_type(info[:type], uppercase: true),
|
|
84
|
+
description: info[:description]
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
{
|
|
89
|
+
name: tool.name,
|
|
90
|
+
description: tool.description,
|
|
91
|
+
parameters: {
|
|
92
|
+
type: "OBJECT",
|
|
93
|
+
properties: properties,
|
|
94
|
+
required: tool.required_parameters
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def format_messages(messages)
|
|
103
|
+
messages.map do |msg|
|
|
104
|
+
role = case msg[:role].to_s
|
|
105
|
+
when "user" then "user"
|
|
106
|
+
when "assistant" then "model"
|
|
107
|
+
when "tool" then "tool"
|
|
108
|
+
else "user"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
parts = []
|
|
112
|
+
if msg[:tool_calls]&.any?
|
|
113
|
+
msg[:tool_calls].each do |tc|
|
|
114
|
+
parts << {
|
|
115
|
+
functionCall: {
|
|
116
|
+
name: tc[:name],
|
|
117
|
+
args: tc[:args]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
elsif role == "tool"
|
|
122
|
+
parts << {
|
|
123
|
+
functionResponse: {
|
|
124
|
+
name: msg[:name],
|
|
125
|
+
response: { result: msg[:content] }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
parts << { text: msg[:content].to_s }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
{ role: role, parts: parts }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def parse_response(response)
|
|
137
|
+
candidate = response.dig("candidates", 0)
|
|
138
|
+
return { role: "assistant", content: "Error: No response from model." } unless candidate
|
|
139
|
+
|
|
140
|
+
parts = candidate.dig("content", "parts") || []
|
|
141
|
+
text = nil
|
|
142
|
+
tool_calls = []
|
|
143
|
+
|
|
144
|
+
parts.each do |part|
|
|
145
|
+
if part["text"]
|
|
146
|
+
text = part["text"]
|
|
147
|
+
elsif part["functionCall"]
|
|
148
|
+
fc = part["functionCall"]
|
|
149
|
+
# Convert args keys to symbols
|
|
150
|
+
args = {}
|
|
151
|
+
if fc["args"]
|
|
152
|
+
fc["args"].each { |k, v| args[k.to_sym] = v }
|
|
153
|
+
end
|
|
154
|
+
tool_calls << {
|
|
155
|
+
id: fc["name"], # Gemini doesn't have tool call IDs, use name
|
|
156
|
+
name: fc["name"],
|
|
157
|
+
args: args
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
result = { role: "assistant" }
|
|
163
|
+
result[:content] = text if text
|
|
164
|
+
result[:tool_calls] = tool_calls if tool_calls.any?
|
|
165
|
+
result
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|