rails-agent-stack 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/CHANGELOG.md +16 -0
- data/MIT-LICENSE +21 -0
- data/README.md +463 -0
- data/lib/generators/rails_agents/install_generator.rb +29 -0
- data/lib/generators/rails_agents/templates/initializer.rb +6 -0
- data/lib/rails/agent/stack.rb +4 -0
- data/lib/rails_agents/agent.rb +128 -0
- data/lib/rails_agents/configuration.rb +35 -0
- data/lib/rails_agents/error.rb +7 -0
- data/lib/rails_agents/generated_file.rb +15 -0
- data/lib/rails_agents/message.rb +27 -0
- data/lib/rails_agents/providers/anthropic/files.rb +51 -0
- data/lib/rails_agents/providers/anthropic.rb +111 -0
- data/lib/rails_agents/providers/base.rb +23 -0
- data/lib/rails_agents/providers/grok.rb +15 -0
- data/lib/rails_agents/providers/open_router.rb +19 -0
- data/lib/rails_agents/providers/openai.rb +15 -0
- data/lib/rails_agents/providers/openai_compatible.rb +78 -0
- data/lib/rails_agents/railtie.rb +25 -0
- data/lib/rails_agents/result.rb +13 -0
- data/lib/rails_agents/runner.rb +146 -0
- data/lib/rails_agents/skill.rb +24 -0
- data/lib/rails_agents/skill_declaration.rb +8 -0
- data/lib/rails_agents/skill_set.rb +139 -0
- data/lib/rails_agents/skills/anthropic_content.rb +29 -0
- data/lib/rails_agents/skills/portable/web_fetch.rb +29 -0
- data/lib/rails_agents/skills/portable/web_search.rb +33 -0
- data/lib/rails_agents/skills/registry.rb +67 -0
- data/lib/rails_agents/tool.rb +48 -0
- data/lib/rails_agents/tool_set.rb +46 -0
- data/lib/rails_agents/version.rb +5 -0
- data/lib/rails_agents.rb +44 -0
- data/rails-agent-stack.gemspec +49 -0
- metadata +150 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module RailsAgents
|
|
7
|
+
class Configuration
|
|
8
|
+
PROVIDERS = %i[openai anthropic openrouter grok].freeze
|
|
9
|
+
|
|
10
|
+
attr_accessor :openai_api_key, :anthropic_api_key, :openrouter_api_key, :grok_api_key,
|
|
11
|
+
:default_provider, :anthropic_auto_download_files, :anthropic_files_directory
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@openai_api_key = ENV["OPENAI_API_KEY"]
|
|
15
|
+
@anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
|
|
16
|
+
@openrouter_api_key = ENV["OPENROUTER_API_KEY"]
|
|
17
|
+
@grok_api_key = ENV["XAI_API_KEY"] || ENV["GROK_API_KEY"]
|
|
18
|
+
@default_provider = :openai
|
|
19
|
+
@anthropic_auto_download_files = true
|
|
20
|
+
@anthropic_files_directory = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def api_key_for(provider)
|
|
24
|
+
public_send(:"#{provider}_api_key")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def anthropic_files_directory!
|
|
28
|
+
return Pathname.new(@anthropic_files_directory) if @anthropic_files_directory.to_s != ""
|
|
29
|
+
|
|
30
|
+
return Rails.root.join("tmp/rails_agents/files") if defined?(Rails)
|
|
31
|
+
|
|
32
|
+
Pathname.new(Dir.mktmpdir("rails_agents_files_"))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgents
|
|
4
|
+
GeneratedFile = Data.define(:file_id, :filename, :content_type, :data, :path) do
|
|
5
|
+
def save(directory)
|
|
6
|
+
dir = File.expand_path(directory)
|
|
7
|
+
FileUtils.mkdir_p(dir)
|
|
8
|
+
destination = File.join(dir, filename)
|
|
9
|
+
File.binwrite(destination, data)
|
|
10
|
+
self.class.new(file_id:, filename:, content_type:, data:, path: destination)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def size = data.bytesize
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgents
|
|
4
|
+
Message = Data.define(:role, :content, :tool_calls, :tool_call_id, :raw_content) do
|
|
5
|
+
def self.system(content) = new(role: :system, content:, tool_calls: nil, tool_call_id: nil, raw_content: nil)
|
|
6
|
+
def self.user(content) = new(role: :user, content:, tool_calls: nil, tool_call_id: nil, raw_content: nil)
|
|
7
|
+
def self.assistant(content, tool_calls: nil, raw_content: nil)
|
|
8
|
+
new(role: :assistant, content:, tool_calls:, tool_call_id: nil, raw_content:)
|
|
9
|
+
end
|
|
10
|
+
def self.tool(content, tool_call_id:) = new(role: :tool, content:, tool_calls: nil, tool_call_id:, raw_content: nil)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
ToolCall = Data.define(:id, :name, :arguments)
|
|
14
|
+
Usage = Data.define(:input_tokens, :output_tokens) do
|
|
15
|
+
def total = input_tokens + output_tokens
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
ProviderResponse = Data.define(
|
|
19
|
+
:content,
|
|
20
|
+
:tool_calls,
|
|
21
|
+
:usage,
|
|
22
|
+
:finish_reason,
|
|
23
|
+
:content_blocks,
|
|
24
|
+
:file_ids,
|
|
25
|
+
:assistant_raw_content
|
|
26
|
+
)
|
|
27
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "faraday"
|
|
5
|
+
|
|
6
|
+
module RailsAgents
|
|
7
|
+
module Providers
|
|
8
|
+
class Anthropic
|
|
9
|
+
class Files
|
|
10
|
+
FILES_BETA = "files-api-2025-04-14"
|
|
11
|
+
API_VERSION = "2023-06-01"
|
|
12
|
+
|
|
13
|
+
def initialize(api_key: RailsAgents.config.anthropic_api_key)
|
|
14
|
+
raise ConfigurationError, "Set anthropic_api_key in config/initializers/rails_agents.rb" if api_key.to_s.empty?
|
|
15
|
+
@api_key = api_key
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def download(file_id)
|
|
19
|
+
metadata = retrieve(file_id)
|
|
20
|
+
response = connection.get("files/#{file_id}/content")
|
|
21
|
+
raise ProviderError, "Failed to download file #{file_id}: #{response.body}" unless response.success?
|
|
22
|
+
|
|
23
|
+
GeneratedFile.new(
|
|
24
|
+
file_id: file_id,
|
|
25
|
+
filename: metadata.fetch("filename"),
|
|
26
|
+
content_type: metadata.fetch("mime_type"),
|
|
27
|
+
data: response.body,
|
|
28
|
+
path: nil
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def retrieve(file_id)
|
|
33
|
+
response = connection.get("files/#{file_id}")
|
|
34
|
+
raise ProviderError, "Failed to retrieve file #{file_id}: #{response.body}" unless response.success?
|
|
35
|
+
|
|
36
|
+
JSON.parse(response.body)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def connection
|
|
42
|
+
@connection ||= Faraday.new(url: "https://api.anthropic.com/v1") do |f|
|
|
43
|
+
f.headers["x-api-key"] = @api_key
|
|
44
|
+
f.headers["anthropic-version"] = API_VERSION
|
|
45
|
+
f.headers["anthropic-beta"] = FILES_BETA
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "faraday"
|
|
5
|
+
|
|
6
|
+
module RailsAgents
|
|
7
|
+
module Providers
|
|
8
|
+
class Anthropic < Base
|
|
9
|
+
API_VERSION = "2023-06-01"
|
|
10
|
+
|
|
11
|
+
def initialize(api_key: RailsAgents.config.anthropic_api_key)
|
|
12
|
+
raise ConfigurationError, "Set anthropic_api_key in config/initializers/rails_agents.rb" if api_key.to_s.empty?
|
|
13
|
+
@api_key = api_key
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def chat(messages:, client_tools: [], skills: nil, model:, json: false, &block)
|
|
17
|
+
anthropic_skills = skills&.anthropic_request || {}
|
|
18
|
+
body = build_body(messages:, client_tools:, skills: anthropic_skills, model:)
|
|
19
|
+
|
|
20
|
+
response = connection(anthropic_skills[:beta_headers]).post("messages", body.to_json)
|
|
21
|
+
raise ProviderError, parse_error(response) unless response.success?
|
|
22
|
+
|
|
23
|
+
parse_response(JSON.parse(response.body), json:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def files = @files ||= Files.new(api_key: @api_key)
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_body(messages:, client_tools:, skills:, model:)
|
|
31
|
+
system, conversation = split_system(messages)
|
|
32
|
+
tools = client_tools.map { |tool|
|
|
33
|
+
{name: tool[:name], description: tool[:description], input_schema: tool[:parameters]}
|
|
34
|
+
}
|
|
35
|
+
tools.concat(skills.fetch(:server_tools, []))
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
model: model,
|
|
39
|
+
max_tokens: 16_384,
|
|
40
|
+
system: system,
|
|
41
|
+
messages: serialize(conversation),
|
|
42
|
+
tools: tools.empty? ? nil : tools,
|
|
43
|
+
container: skills[:container]
|
|
44
|
+
}.compact
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse_response(data, json:)
|
|
48
|
+
content_blocks = data.fetch("content")
|
|
49
|
+
text = Skills::AnthropicContent.extract_text(content_blocks)
|
|
50
|
+
text = "{}" if json && text.blank?
|
|
51
|
+
tool_blocks = content_blocks.select { |block| block["type"] == "tool_use" }
|
|
52
|
+
usage = data.fetch("usage", {})
|
|
53
|
+
|
|
54
|
+
ProviderResponse.new(
|
|
55
|
+
content: text.to_s.empty? ? nil : text,
|
|
56
|
+
tool_calls: tool_blocks.map { |block|
|
|
57
|
+
ToolCall.new(id: block["id"], name: block["name"], arguments: block["input"] || {})
|
|
58
|
+
},
|
|
59
|
+
usage: Usage.new(usage.fetch("input_tokens", 0), usage.fetch("output_tokens", 0)),
|
|
60
|
+
finish_reason: data["stop_reason"],
|
|
61
|
+
content_blocks: content_blocks,
|
|
62
|
+
file_ids: Skills::AnthropicContent.extract_file_ids(content_blocks),
|
|
63
|
+
assistant_raw_content: content_blocks
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def connection(beta_headers = [])
|
|
68
|
+
headers = {
|
|
69
|
+
"x-api-key" => @api_key,
|
|
70
|
+
"anthropic-version" => API_VERSION,
|
|
71
|
+
"Content-Type" => "application/json"
|
|
72
|
+
}
|
|
73
|
+
headers["anthropic-beta"] = beta_headers.join(",") if beta_headers&.any?
|
|
74
|
+
|
|
75
|
+
Faraday.new(url: "https://api.anthropic.com/v1", headers: headers)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def split_system(messages)
|
|
79
|
+
system = messages.select { |message| message.role == :system }.map(&:content).join("\n")
|
|
80
|
+
conversation = messages.reject { |message| message.role == :system }
|
|
81
|
+
[system.to_s.empty? ? nil : system, conversation]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def serialize(messages)
|
|
85
|
+
messages.filter_map do |message|
|
|
86
|
+
case message.role
|
|
87
|
+
when :user
|
|
88
|
+
{role: "user", content: message.content}
|
|
89
|
+
when :assistant
|
|
90
|
+
if message.raw_content
|
|
91
|
+
{role: "assistant", content: message.raw_content}
|
|
92
|
+
else
|
|
93
|
+
blocks = []
|
|
94
|
+
blocks << {type: "text", text: message.content} if message.content.present?
|
|
95
|
+
message.tool_calls&.each do |tool_call|
|
|
96
|
+
blocks << {type: "tool_use", id: tool_call.id, name: tool_call.name, input: tool_call.arguments}
|
|
97
|
+
end
|
|
98
|
+
{role: "assistant", content: blocks}
|
|
99
|
+
end
|
|
100
|
+
when :tool
|
|
101
|
+
{role: "user", content: [{type: "tool_result", tool_use_id: message.tool_call_id, content: message.content}]}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse_error(response)
|
|
107
|
+
JSON.parse(response.body).dig("error", "message") rescue response.body
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgents
|
|
4
|
+
module Providers
|
|
5
|
+
class Base
|
|
6
|
+
def chat(messages:, tools: [], client_tools: tools, skills: nil, model:, json: false, &block)
|
|
7
|
+
raise NotImplementedError
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.build(name, api_key: nil)
|
|
12
|
+
kwargs = api_key.nil? ? {} : {api_key: api_key}
|
|
13
|
+
|
|
14
|
+
case name.to_sym
|
|
15
|
+
when :openai then OpenAI.new(**kwargs)
|
|
16
|
+
when :anthropic then Anthropic.new(**kwargs)
|
|
17
|
+
when :openrouter then OpenRouter.new(**kwargs)
|
|
18
|
+
when :grok then Grok.new(**kwargs)
|
|
19
|
+
else raise ConfigurationError, "Unknown provider: #{name}. Use: #{Configuration::PROVIDERS.join(', ')}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgents
|
|
4
|
+
module Providers
|
|
5
|
+
class Grok < OpenAICompatible
|
|
6
|
+
def initialize(api_key: RailsAgents.config.grok_api_key, base_url: "https://api.x.ai/v1")
|
|
7
|
+
super(
|
|
8
|
+
api_key: api_key,
|
|
9
|
+
base_url: base_url,
|
|
10
|
+
missing_key_message: "Set grok_api_key (or XAI_API_KEY) in config/initializers/rails_agents.rb"
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgents
|
|
4
|
+
module Providers
|
|
5
|
+
class OpenRouter < OpenAICompatible
|
|
6
|
+
def initialize(api_key: RailsAgents.config.openrouter_api_key, base_url: "https://openrouter.ai/api/v1")
|
|
7
|
+
super(
|
|
8
|
+
api_key: api_key,
|
|
9
|
+
base_url: base_url,
|
|
10
|
+
extra_headers: {
|
|
11
|
+
"HTTP-Referer" => (defined?(Rails) ? "https://#{Rails.application.class.module_parent_name.downcase}.app" : "https://railsagents.dev"),
|
|
12
|
+
"X-Title" => (defined?(Rails) ? Rails.application.class.module_parent_name : "Rails Agents")
|
|
13
|
+
},
|
|
14
|
+
missing_key_message: "Set openrouter_api_key in config/initializers/rails_agents.rb"
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgents
|
|
4
|
+
module Providers
|
|
5
|
+
class OpenAI < OpenAICompatible
|
|
6
|
+
def initialize(api_key: RailsAgents.config.openai_api_key, base_url: "https://api.openai.com/v1")
|
|
7
|
+
super(
|
|
8
|
+
api_key: api_key,
|
|
9
|
+
base_url: base_url,
|
|
10
|
+
missing_key_message: "Set openai_api_key in config/initializers/rails_agents.rb"
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "faraday"
|
|
5
|
+
|
|
6
|
+
module RailsAgents
|
|
7
|
+
module Providers
|
|
8
|
+
class OpenAICompatible < Base
|
|
9
|
+
def initialize(api_key:, base_url:, extra_headers: {}, missing_key_message:)
|
|
10
|
+
raise ConfigurationError, missing_key_message if api_key.to_s.empty?
|
|
11
|
+
@api_key = api_key
|
|
12
|
+
@base_url = base_url
|
|
13
|
+
@extra_headers = extra_headers
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def chat(messages:, client_tools: [], skills: nil, model:, json: false, &block)
|
|
17
|
+
tools = client_tools
|
|
18
|
+
body = {
|
|
19
|
+
model: model,
|
|
20
|
+
messages: serialize(messages),
|
|
21
|
+
tools: tools.empty? ? nil : tools.map { |t| {type: "function", function: t} }
|
|
22
|
+
}.compact
|
|
23
|
+
body[:response_format] = {type: "json_object"} if json
|
|
24
|
+
|
|
25
|
+
response = connection.post("chat/completions", body.to_json)
|
|
26
|
+
raise ProviderError, parse_error(response) unless response.success?
|
|
27
|
+
|
|
28
|
+
data = JSON.parse(response.body)
|
|
29
|
+
choice = data.fetch("choices").first
|
|
30
|
+
message = choice.fetch("message")
|
|
31
|
+
usage = data.fetch("usage", {})
|
|
32
|
+
|
|
33
|
+
ProviderResponse.new(
|
|
34
|
+
content: message["content"],
|
|
35
|
+
tool_calls: (message["tool_calls"] || []).map { |tc|
|
|
36
|
+
ToolCall.new(
|
|
37
|
+
id: tc["id"],
|
|
38
|
+
name: tc.dig("function", "name"),
|
|
39
|
+
arguments: JSON.parse(tc.dig("function", "arguments") || "{}")
|
|
40
|
+
)
|
|
41
|
+
},
|
|
42
|
+
usage: Usage.new(usage.fetch("prompt_tokens", 0), usage.fetch("completion_tokens", 0)),
|
|
43
|
+
finish_reason: choice["finish_reason"],
|
|
44
|
+
content_blocks: nil,
|
|
45
|
+
file_ids: [],
|
|
46
|
+
assistant_raw_content: nil
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def connection
|
|
53
|
+
@connection ||= Faraday.new(url: @base_url) do |f|
|
|
54
|
+
f.headers["Authorization"] = "Bearer #{@api_key}"
|
|
55
|
+
f.headers["Content-Type"] = "application/json"
|
|
56
|
+
@extra_headers.each { |key, value| f.headers[key] = value }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def serialize(messages)
|
|
61
|
+
messages.map do |msg|
|
|
62
|
+
h = {role: msg.role.to_s, content: msg.content}
|
|
63
|
+
if msg.tool_calls&.any?
|
|
64
|
+
h[:tool_calls] = msg.tool_calls.map { |tc|
|
|
65
|
+
{id: tc.id, type: "function", function: {name: tc.name, arguments: tc.arguments.to_json}}
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
h[:tool_call_id] = msg.tool_call_id if msg.tool_call_id
|
|
69
|
+
h
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_error(response)
|
|
74
|
+
JSON.parse(response.body).dig("error", "message") rescue response.body
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_agents"
|
|
4
|
+
|
|
5
|
+
module RailsAgents
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
generators do
|
|
8
|
+
require "generators/rails_agents/install_generator"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "rails_agents.autoload", before: :set_autoload_paths do |app|
|
|
12
|
+
agents_path = app.root.join("app/agents")
|
|
13
|
+
next unless agents_path.exist?
|
|
14
|
+
|
|
15
|
+
app.autoloaders.main.push_dir(agents_path.to_s)
|
|
16
|
+
|
|
17
|
+
tools_path = agents_path.join("tools")
|
|
18
|
+
app.autoloaders.main.collapse(tools_path.to_s) if tools_path.exist?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
rake_tasks do
|
|
22
|
+
load "rails_agents/tasks.rb" if File.exist?(File.expand_path("tasks.rb", __dir__))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgents
|
|
4
|
+
Result = Data.define(:output, :data, :files, :messages, :usage, :success, :error, :content_blocks) do
|
|
5
|
+
def self.ok(output:, files: [], data: nil, messages: [], usage: Usage.new(0, 0), content_blocks: nil)
|
|
6
|
+
new(output:, data:, files:, messages:, usage:, success: true, error: nil, content_blocks:)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.fail(error:, messages: [], usage: Usage.new(0, 0), content_blocks: nil)
|
|
10
|
+
new(output: nil, data: nil, files: [], messages:, usage:, success: false, error:, content_blocks:)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgents
|
|
4
|
+
class Runner
|
|
5
|
+
DEFAULT_TURNS = 12
|
|
6
|
+
|
|
7
|
+
def initialize(agent_class, input:, context: {}, callbacks: {}, parse_json: false, provider: nil, model: nil)
|
|
8
|
+
@agent = agent_class
|
|
9
|
+
@input = input
|
|
10
|
+
@context = context
|
|
11
|
+
@callbacks = callbacks
|
|
12
|
+
@parse_json = parse_json
|
|
13
|
+
@skills = agent_class.skill_set
|
|
14
|
+
@tools = agent_class.tool_set
|
|
15
|
+
@provider = provider || agent_class.provider_client
|
|
16
|
+
@model_override = model
|
|
17
|
+
@server_tool_names = @skills.server_tool_names
|
|
18
|
+
@save_files_to = context[:save_files_to]
|
|
19
|
+
@messages = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
emit(:start, agent: @agent.name, input: @input, context: @context)
|
|
24
|
+
|
|
25
|
+
@messages << Message.system(@agent.render_instructions(@context))
|
|
26
|
+
@messages << Message.user(format_input(@input))
|
|
27
|
+
|
|
28
|
+
turns = 0
|
|
29
|
+
total_usage = Usage.new(0, 0)
|
|
30
|
+
last_response = nil
|
|
31
|
+
|
|
32
|
+
loop do
|
|
33
|
+
turns += 1
|
|
34
|
+
raise Error, "Turn limit reached" if turns > @agent.max_turns
|
|
35
|
+
|
|
36
|
+
emit(:turn_start, turn: turns)
|
|
37
|
+
|
|
38
|
+
response = @provider.chat(
|
|
39
|
+
messages: @messages,
|
|
40
|
+
client_tools: @tools.definitions,
|
|
41
|
+
skills: @skills,
|
|
42
|
+
model: @model_override || @agent.resolved_model
|
|
43
|
+
)
|
|
44
|
+
last_response = response
|
|
45
|
+
|
|
46
|
+
total_usage = Usage.new(
|
|
47
|
+
total_usage.input_tokens + response.usage.input_tokens,
|
|
48
|
+
total_usage.output_tokens + response.usage.output_tokens
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
emit(:turn_complete, turn: turns, usage: response.usage, tool_calls: response.tool_calls.map(&:name))
|
|
52
|
+
|
|
53
|
+
client_tool_calls = response.tool_calls.reject { |tool_call| @server_tool_names.include?(tool_call.name) }
|
|
54
|
+
|
|
55
|
+
if client_tool_calls.any?
|
|
56
|
+
@messages << Message.assistant(
|
|
57
|
+
response.content,
|
|
58
|
+
tool_calls: client_tool_calls,
|
|
59
|
+
raw_content: response.assistant_raw_content
|
|
60
|
+
)
|
|
61
|
+
client_tool_calls.each do |tool_call|
|
|
62
|
+
emit(:tool_start, name: tool_call.name, arguments: tool_call.arguments)
|
|
63
|
+
result = @tools.execute(tool_call.name, tool_call.arguments)
|
|
64
|
+
emit(:tool_finish, name: tool_call.name, result: result)
|
|
65
|
+
@messages << Message.tool(result.to_json, tool_call_id: tool_call.id)
|
|
66
|
+
end
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
files = download_files(response.file_ids)
|
|
71
|
+
data = parse_structured_output(response.content)
|
|
72
|
+
result = Result.ok(
|
|
73
|
+
output: response.content,
|
|
74
|
+
data: data,
|
|
75
|
+
files: files,
|
|
76
|
+
messages: @messages,
|
|
77
|
+
usage: total_usage,
|
|
78
|
+
content_blocks: response.content_blocks
|
|
79
|
+
)
|
|
80
|
+
emit(:finish, result: result)
|
|
81
|
+
return result
|
|
82
|
+
end
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
result = Result.fail(error: e.message, messages: @messages, usage: Usage.new(0, 0))
|
|
85
|
+
emit(:error, error: e.message, result: result)
|
|
86
|
+
result
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def emit(event, payload = {})
|
|
92
|
+
@callbacks[event]&.call(payload)
|
|
93
|
+
instrument(event, payload)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def instrument(event, payload)
|
|
97
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
98
|
+
|
|
99
|
+
ActiveSupport::Notifications.instrument(
|
|
100
|
+
"rails_agents.#{event}",
|
|
101
|
+
payload.merge(agent: @agent.name)
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse_structured_output(content)
|
|
106
|
+
return nil unless @parse_json
|
|
107
|
+
return nil if content.nil? || content.strip.empty?
|
|
108
|
+
|
|
109
|
+
JSON.parse(content)
|
|
110
|
+
rescue JSON::ParserError
|
|
111
|
+
extract_json_object(content)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def extract_json_object(content)
|
|
115
|
+
match = content.match(/\{.*\}/m)
|
|
116
|
+
return nil unless match
|
|
117
|
+
|
|
118
|
+
JSON.parse(match[0])
|
|
119
|
+
rescue JSON::ParserError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def format_input(input)
|
|
124
|
+
case input
|
|
125
|
+
when String then input
|
|
126
|
+
when Hash then input.to_json
|
|
127
|
+
else input.to_s
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def download_files(file_ids)
|
|
132
|
+
return [] if file_ids.empty?
|
|
133
|
+
return [] unless @agent.resolved_provider == :anthropic
|
|
134
|
+
return [] unless RailsAgents.config.anthropic_auto_download_files
|
|
135
|
+
return [] unless @provider.respond_to?(:files)
|
|
136
|
+
|
|
137
|
+
directory = @save_files_to || RailsAgents.config.anthropic_files_directory!
|
|
138
|
+
FileUtils.mkdir_p(directory)
|
|
139
|
+
|
|
140
|
+
file_ids.map do |file_id|
|
|
141
|
+
generated = @provider.files.download(file_id)
|
|
142
|
+
generated.save(directory)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgents
|
|
4
|
+
class Skill
|
|
5
|
+
SERVER = :server
|
|
6
|
+
PORTABLE = :portable
|
|
7
|
+
ANTHROPIC_DOCUMENT = :anthropic_document
|
|
8
|
+
|
|
9
|
+
attr_reader :name, :kind, :anthropic_tool, :anthropic_skill, :requires, :portable_tool
|
|
10
|
+
|
|
11
|
+
def initialize(name:, kind:, anthropic_tool: nil, anthropic_skill: nil, requires: [], portable_tool: nil)
|
|
12
|
+
@name = name.to_sym
|
|
13
|
+
@kind = kind
|
|
14
|
+
@anthropic_tool = anthropic_tool
|
|
15
|
+
@anthropic_skill = anthropic_skill
|
|
16
|
+
@requires = requires.map(&:to_sym)
|
|
17
|
+
@portable_tool = portable_tool
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def server? = kind == SERVER
|
|
21
|
+
def portable? = kind == PORTABLE
|
|
22
|
+
def anthropic_document? = kind == ANTHROPIC_DOCUMENT
|
|
23
|
+
end
|
|
24
|
+
end
|