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.
@@ -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