ruboty-ai_agent 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/.gitignore +19 -0
- data/.rspec +3 -0
- data/.rubocop.yml +45 -0
- data/AGENTS.md +22 -0
- data/CHANGELOG.md +3 -0
- data/CLAUDE.md +1 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +14 -0
- data/README.md +118 -0
- data/Rakefile +47 -0
- data/Steepfile +12 -0
- data/bin/console +15 -0
- data/bin/ruboty +34 -0
- data/bin/setup +8 -0
- data/lib/ruboty/ai_agent/actions/add_ai_command.rb +22 -0
- data/lib/ruboty/ai_agent/actions/add_ai_memory.rb +20 -0
- data/lib/ruboty/ai_agent/actions/add_mcp.rb +94 -0
- data/lib/ruboty/ai_agent/actions/base.rb +43 -0
- data/lib/ruboty/ai_agent/actions/chat.rb +64 -0
- data/lib/ruboty/ai_agent/actions/list_ai_commands.rb +19 -0
- data/lib/ruboty/ai_agent/actions/list_ai_memories.rb +18 -0
- data/lib/ruboty/ai_agent/actions/list_mcp.rb +18 -0
- data/lib/ruboty/ai_agent/actions/remove_ai_command.rb +18 -0
- data/lib/ruboty/ai_agent/actions/remove_ai_memory.rb +25 -0
- data/lib/ruboty/ai_agent/actions/remove_mcp.rb +24 -0
- data/lib/ruboty/ai_agent/actions/set_system_prompt.rb +31 -0
- data/lib/ruboty/ai_agent/actions/show_system_prompt.rb +30 -0
- data/lib/ruboty/ai_agent/actions.rb +22 -0
- data/lib/ruboty/ai_agent/agent.rb +71 -0
- data/lib/ruboty/ai_agent/cached_value.rb +43 -0
- data/lib/ruboty/ai_agent/chat_message.rb +60 -0
- data/lib/ruboty/ai_agent/chat_thread.rb +31 -0
- data/lib/ruboty/ai_agent/chat_thread_associations.rb +34 -0
- data/lib/ruboty/ai_agent/chat_thread_messages.rb +17 -0
- data/lib/ruboty/ai_agent/commands/base.rb +39 -0
- data/lib/ruboty/ai_agent/commands/clear.rb +29 -0
- data/lib/ruboty/ai_agent/commands/compact.rb +80 -0
- data/lib/ruboty/ai_agent/commands/usage.rb +52 -0
- data/lib/ruboty/ai_agent/commands.rb +33 -0
- data/lib/ruboty/ai_agent/database/query_methods.rb +84 -0
- data/lib/ruboty/ai_agent/database.rb +40 -0
- data/lib/ruboty/ai_agent/global_settings.rb +33 -0
- data/lib/ruboty/ai_agent/http_mcp_client.rb +215 -0
- data/lib/ruboty/ai_agent/llm/openai/model.rb +29 -0
- data/lib/ruboty/ai_agent/llm/openai.rb +181 -0
- data/lib/ruboty/ai_agent/llm/response.rb +21 -0
- data/lib/ruboty/ai_agent/llm.rb +11 -0
- data/lib/ruboty/ai_agent/mcp_clients.rb +48 -0
- data/lib/ruboty/ai_agent/mcp_configuration.rb +31 -0
- data/lib/ruboty/ai_agent/record_set.rb +71 -0
- data/lib/ruboty/ai_agent/recordable.rb +116 -0
- data/lib/ruboty/ai_agent/token_usage.rb +45 -0
- data/lib/ruboty/ai_agent/tool.rb +29 -0
- data/lib/ruboty/ai_agent/user.rb +52 -0
- data/lib/ruboty/ai_agent/user_ai_memories.rb +17 -0
- data/lib/ruboty/ai_agent/user_associations.rb +34 -0
- data/lib/ruboty/ai_agent/user_mcp_caches.rb +90 -0
- data/lib/ruboty/ai_agent/user_mcp_client.rb +93 -0
- data/lib/ruboty/ai_agent/user_mcp_configurations.rb +15 -0
- data/lib/ruboty/ai_agent/user_mcp_tools_caches.rb +14 -0
- data/lib/ruboty/ai_agent/version.rb +7 -0
- data/lib/ruboty/ai_agent.rb +40 -0
- data/lib/ruboty/handlers/ai_agent.rb +84 -0
- data/rbs_collection.yaml +23 -0
- data/ruboty-ai_agent.gemspec +49 -0
- data/script/generate-concern-rbs.rb +351 -0
- data/script/generate-data-rbs.rb +250 -0
- data/script/generate-memorized-ivar-rbs.rb +292 -0
- data/sig/generated/ruboty/ai_agent/actions/add_ai_command.rbs +16 -0
- data/sig/generated/ruboty/ai_agent/actions/add_ai_memory.rbs +14 -0
- data/sig/generated/ruboty/ai_agent/actions/add_mcp.rbs +26 -0
- data/sig/generated/ruboty/ai_agent/actions/base.rbs +34 -0
- data/sig/generated/ruboty/ai_agent/actions/chat.rbs +17 -0
- data/sig/generated/ruboty/ai_agent/actions/list_ai_commands.rbs +13 -0
- data/sig/generated/ruboty/ai_agent/actions/list_ai_memories.rbs +12 -0
- data/sig/generated/ruboty/ai_agent/actions/list_mcp.rbs +12 -0
- data/sig/generated/ruboty/ai_agent/actions/remove_ai_command.rbs +14 -0
- data/sig/generated/ruboty/ai_agent/actions/remove_ai_memory.rbs +14 -0
- data/sig/generated/ruboty/ai_agent/actions/remove_mcp.rbs +14 -0
- data/sig/generated/ruboty/ai_agent/actions/set_system_prompt.rbs +16 -0
- data/sig/generated/ruboty/ai_agent/actions/show_system_prompt.rbs +12 -0
- data/sig/generated/ruboty/ai_agent/actions.rbs +9 -0
- data/sig/generated/ruboty/ai_agent/agent.rbs +29 -0
- data/sig/generated/ruboty/ai_agent/cached_value.rbs +28 -0
- data/sig/generated/ruboty/ai_agent/chat_message.rbs +34 -0
- data/sig/generated/ruboty/ai_agent/chat_thread.rbs +22 -0
- data/sig/generated/ruboty/ai_agent/chat_thread_associations.rbs +21 -0
- data/sig/generated/ruboty/ai_agent/chat_thread_messages.rbs +13 -0
- data/sig/generated/ruboty/ai_agent/commands/base.rbs +40 -0
- data/sig/generated/ruboty/ai_agent/commands/clear.rbs +20 -0
- data/sig/generated/ruboty/ai_agent/commands/compact.rbs +30 -0
- data/sig/generated/ruboty/ai_agent/commands/usage.rbs +26 -0
- data/sig/generated/ruboty/ai_agent/commands.rbs +13 -0
- data/sig/generated/ruboty/ai_agent/database/query_methods.rbs +39 -0
- data/sig/generated/ruboty/ai_agent/database.rbs +27 -0
- data/sig/generated/ruboty/ai_agent/global_settings.rbs +23 -0
- data/sig/generated/ruboty/ai_agent/http_mcp_client.rbs +62 -0
- data/sig/generated/ruboty/ai_agent/llm/openai/model.rbs +21 -0
- data/sig/generated/ruboty/ai_agent/llm/openai.rbs +54 -0
- data/sig/generated/ruboty/ai_agent/llm/response.rbs +29 -0
- data/sig/generated/ruboty/ai_agent/llm.rbs +9 -0
- data/sig/generated/ruboty/ai_agent/mcp_clients.rbs +24 -0
- data/sig/generated/ruboty/ai_agent/mcp_configuration.rbs +35 -0
- data/sig/generated/ruboty/ai_agent/record_set.rbs +42 -0
- data/sig/generated/ruboty/ai_agent/recordable.rbs +56 -0
- data/sig/generated/ruboty/ai_agent/token_usage.rbs +30 -0
- data/sig/generated/ruboty/ai_agent/tool.rbs +27 -0
- data/sig/generated/ruboty/ai_agent/user.rbs +35 -0
- data/sig/generated/ruboty/ai_agent/user_ai_memories.rbs +11 -0
- data/sig/generated/ruboty/ai_agent/user_associations.rbs +21 -0
- data/sig/generated/ruboty/ai_agent/user_mcp_caches.rbs +44 -0
- data/sig/generated/ruboty/ai_agent/user_mcp_client.rbs +58 -0
- data/sig/generated/ruboty/ai_agent/user_mcp_configurations.rbs +11 -0
- data/sig/generated/ruboty/ai_agent/user_mcp_tools_caches.rbs +11 -0
- data/sig/generated/ruboty/ai_agent/version.rbs +7 -0
- data/sig/generated/ruboty/ai_agent.rbs +9 -0
- data/sig/generated/ruboty/handlers/ai_agent.rbs +32 -0
- data/sig/generated-by-scripts/concerns.rbs +27 -0
- data/sig/generated-by-scripts/memorized_ivars.rbs +42 -0
- data/sig-lib/event_stream_parser/event_stream_parser.rbs +21 -0
- data/sig-lib/mem/mem.rbs +19 -0
- data/sig-lib/ruboty/ruboty.rbs +421 -0
- metadata +263 -0
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ruboty
|
4
|
+
module AiAgent
|
5
|
+
# Interaction commands (a.k.a. Slash commands, Prompts, etc)
|
6
|
+
module Commands
|
7
|
+
autoload :Base, 'ruboty/ai_agent/commands/base'
|
8
|
+
autoload :Clear, 'ruboty/ai_agent/commands/clear'
|
9
|
+
autoload :Compact, 'ruboty/ai_agent/commands/compact'
|
10
|
+
autoload :Usage, 'ruboty/ai_agent/commands/usage'
|
11
|
+
|
12
|
+
# @rbs message: Ruboty::Message
|
13
|
+
# @rbs chat_thread: ChatThread
|
14
|
+
# @rbs return: Array[Commands::Base]
|
15
|
+
def self.builtins(message:, chat_thread:)
|
16
|
+
[
|
17
|
+
Commands::Clear.new(
|
18
|
+
message:,
|
19
|
+
chat_thread:
|
20
|
+
),
|
21
|
+
Commands::Compact.new(
|
22
|
+
message:,
|
23
|
+
chat_thread:
|
24
|
+
),
|
25
|
+
Commands::Usage.new(
|
26
|
+
message:,
|
27
|
+
chat_thread:
|
28
|
+
)
|
29
|
+
]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ruboty
|
4
|
+
module AiAgent
|
5
|
+
class Database
|
6
|
+
# @rbs!
|
7
|
+
# interface _WithData
|
8
|
+
# def data: -> Hash[keynable, untyped]
|
9
|
+
# end
|
10
|
+
|
11
|
+
# @rbs module-self _WithData
|
12
|
+
module QueryMethods
|
13
|
+
# @rbs *keys: keynable
|
14
|
+
# @rbs return: untyped
|
15
|
+
def fetch(*keys)
|
16
|
+
item = data.dig(*keys)
|
17
|
+
|
18
|
+
Recordable.instantiate_recursively(item)
|
19
|
+
end
|
20
|
+
|
21
|
+
# @rbs *keys: keynable
|
22
|
+
# @rbs return: Integer
|
23
|
+
def len(*keys)
|
24
|
+
item = data.dig(*keys)
|
25
|
+
item.respond_to?(:length) ? item.length : 0
|
26
|
+
end
|
27
|
+
|
28
|
+
# @rbs *keys: keynable
|
29
|
+
# @rbs return: void
|
30
|
+
def delete(*keys)
|
31
|
+
namespace_keys = keys[0..-2] || []
|
32
|
+
key = keys[-1]
|
33
|
+
|
34
|
+
namespace = namespace_keys.empty? ? data : data.dig(*namespace_keys) #: top
|
35
|
+
case namespace
|
36
|
+
when Hash
|
37
|
+
namespace.delete(key)
|
38
|
+
when Array
|
39
|
+
namespace.delete_at(key) if key.is_a?(Integer) && key < namespace.length
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# @rbs *keys: keynable
|
44
|
+
# @rbs return: Array[keynable]
|
45
|
+
def keys(*keys)
|
46
|
+
namespace = keys.empty? ? data : data.dig(*keys) #: top
|
47
|
+
case namespace
|
48
|
+
when Hash
|
49
|
+
namespace.keys
|
50
|
+
when Array
|
51
|
+
namespace.length.times.to_a
|
52
|
+
else
|
53
|
+
[]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# @rbs *keys: keynable
|
58
|
+
# @rbs return: boolish
|
59
|
+
def key?(*keys)
|
60
|
+
namespace_keys = keys[0..-2] || []
|
61
|
+
key = keys[-1]
|
62
|
+
|
63
|
+
namespace = namespace_keys.empty? ? data : data.dig(*namespace_keys)
|
64
|
+
namespace&.key?(key)
|
65
|
+
end
|
66
|
+
|
67
|
+
# @rbs at: Array[keynable]
|
68
|
+
# @rbs value: untyped
|
69
|
+
# @rbs return: void
|
70
|
+
def store(value, at:)
|
71
|
+
namespace_keys = at[0..-2] || []
|
72
|
+
key = at[-1]
|
73
|
+
|
74
|
+
namespace = namespace_keys.reduce(data) do |current, k|
|
75
|
+
current[k] ||= {}
|
76
|
+
current[k]
|
77
|
+
end
|
78
|
+
|
79
|
+
namespace[key] = Recordable.hashify_recursively(value)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ruboty
|
4
|
+
module AiAgent
|
5
|
+
# Memorize and retrieve information using Ruboty's brain.
|
6
|
+
class Database
|
7
|
+
# @rbs!
|
8
|
+
# type keynable = Symbol | String | Integer
|
9
|
+
|
10
|
+
autoload :QueryMethods, 'ruboty/ai_agent/database/query_methods'
|
11
|
+
|
12
|
+
include QueryMethods
|
13
|
+
|
14
|
+
NAMESPACE = :ai_agent
|
15
|
+
|
16
|
+
attr_reader :brain #: Ruboty::Brains::Base
|
17
|
+
|
18
|
+
# @rbs brain: Ruboty::Brains::Base
|
19
|
+
def initialize(brain)
|
20
|
+
@brain = brain
|
21
|
+
end
|
22
|
+
|
23
|
+
def data #: Hash[keynable, untyped]
|
24
|
+
brain.data[NAMESPACE] ||= {} # steep:ignore UnannotatedEmptyCollection
|
25
|
+
end
|
26
|
+
|
27
|
+
def user(id) #: User
|
28
|
+
User.find_or_create(database: self, id: id)
|
29
|
+
end
|
30
|
+
|
31
|
+
def chat_thread(id) #: ChatThread
|
32
|
+
ChatThread.find_or_create(database: self, id: id)
|
33
|
+
end
|
34
|
+
|
35
|
+
def global_settings #: GlobalSettings
|
36
|
+
@global_settings ||= GlobalSettings.find_or_create(database: self)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ruboty
|
4
|
+
module AiAgent
|
5
|
+
# Global settings for AI agent.
|
6
|
+
class GlobalSettings
|
7
|
+
attr_reader :database #: Ruboty::AiAgent::Database
|
8
|
+
|
9
|
+
NAMESPACE_KEYS = [:global_settings].freeze #: Array[Database::keynable]
|
10
|
+
|
11
|
+
class << self
|
12
|
+
def find_or_create(database:) #: GlobalSettings
|
13
|
+
new(database: database)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# @rbs database: Ruboty::AiAgent::Database
|
18
|
+
def initialize(database:)
|
19
|
+
@database = database
|
20
|
+
end
|
21
|
+
|
22
|
+
def system_prompt #: String?
|
23
|
+
database.fetch(*NAMESPACE_KEYS, :system_prompt)
|
24
|
+
end
|
25
|
+
|
26
|
+
# @rbs prompt: String?
|
27
|
+
# @rbs return: void
|
28
|
+
def system_prompt=(prompt)
|
29
|
+
database.store(prompt, at: [*NAMESPACE_KEYS, :system_prompt])
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,215 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'net/http'
|
4
|
+
require 'uri'
|
5
|
+
require 'json'
|
6
|
+
require 'securerandom'
|
7
|
+
require 'event_stream_parser'
|
8
|
+
|
9
|
+
module Ruboty
|
10
|
+
module AiAgent
|
11
|
+
# Mcp client with HTTP transport.
|
12
|
+
class HttpMcpClient
|
13
|
+
attr_reader :base_url, :headers, :session_id
|
14
|
+
|
15
|
+
def initialize(url:, headers: {}, session_id: nil)
|
16
|
+
@base_url = url
|
17
|
+
@headers = headers
|
18
|
+
@session_id = session_id
|
19
|
+
|
20
|
+
@initialize_called = false
|
21
|
+
end
|
22
|
+
|
23
|
+
def initialize_session
|
24
|
+
response = send_request(
|
25
|
+
method: 'initialize',
|
26
|
+
params: {
|
27
|
+
protocolVersion: '2024-11-05',
|
28
|
+
capabilities: {}, #: Hash[untyped, untyped]
|
29
|
+
clientInfo: {
|
30
|
+
name: 'ruboty-ai_agent',
|
31
|
+
version: '1.0'
|
32
|
+
}
|
33
|
+
}
|
34
|
+
).first
|
35
|
+
|
36
|
+
@session_id = response['Mcp-Session-Id'] if response.is_a?(Hash) && response['Mcp-Session-Id']
|
37
|
+
@initialize_called = true
|
38
|
+
@session_id
|
39
|
+
end
|
40
|
+
|
41
|
+
def ping
|
42
|
+
ensure_initialized
|
43
|
+
send_request(method: 'ping')
|
44
|
+
end
|
45
|
+
|
46
|
+
def list_tools
|
47
|
+
ensure_initialized
|
48
|
+
results = send_request(method: 'tools/list')
|
49
|
+
results.flat_map { |res| res.dig('result', 'tools') || [] }
|
50
|
+
end
|
51
|
+
|
52
|
+
# @rbs name: String
|
53
|
+
# @rbs &block: ? (Hash[String, untyped]) -> void
|
54
|
+
def call_tool(name, arguments = {}, &block)
|
55
|
+
ensure_initialized
|
56
|
+
results = send_request(
|
57
|
+
method: 'tools/call',
|
58
|
+
params: {
|
59
|
+
name: name,
|
60
|
+
arguments: arguments
|
61
|
+
},
|
62
|
+
&block
|
63
|
+
)
|
64
|
+
|
65
|
+
results.flat_map { |res| res.dig('result', 'content') || [] }
|
66
|
+
end
|
67
|
+
|
68
|
+
def list_prompts
|
69
|
+
ensure_initialized
|
70
|
+
send_request(method: 'prompts/list')
|
71
|
+
end
|
72
|
+
|
73
|
+
def get_prompt(name, arguments = {})
|
74
|
+
ensure_initialized
|
75
|
+
send_request(
|
76
|
+
method: 'prompts/get',
|
77
|
+
params: {
|
78
|
+
name: name,
|
79
|
+
arguments: arguments
|
80
|
+
}
|
81
|
+
)
|
82
|
+
end
|
83
|
+
|
84
|
+
def list_resources
|
85
|
+
ensure_initialized
|
86
|
+
send_request(method: 'resources/list')
|
87
|
+
end
|
88
|
+
|
89
|
+
def read_resource(uri)
|
90
|
+
ensure_initialized
|
91
|
+
send_request(
|
92
|
+
method: 'resources/read',
|
93
|
+
params: {
|
94
|
+
uri: uri
|
95
|
+
}
|
96
|
+
)
|
97
|
+
end
|
98
|
+
|
99
|
+
def cleanup_session
|
100
|
+
if @session_id
|
101
|
+
uri = URI.parse(@base_url)
|
102
|
+
|
103
|
+
raise 'Invalid uri' if uri.host.nil?
|
104
|
+
|
105
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
106
|
+
http.use_ssl = uri.scheme == 'https'
|
107
|
+
|
108
|
+
request = Net::HTTP::Delete.new(uri.path.nil? || uri.path.empty? ? '/' : uri.path)
|
109
|
+
request['Accept'] = 'application/json, text/event-stream'
|
110
|
+
request['Content-Type'] = 'application/json'
|
111
|
+
request['Mcp-Session-Id'] = @session_id
|
112
|
+
headers.each { |k, v| request[k] = v }
|
113
|
+
|
114
|
+
http.request(request)
|
115
|
+
@session_id = nil
|
116
|
+
end
|
117
|
+
|
118
|
+
@initialize_called = false
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
|
123
|
+
# @rbs @initialize_called: bool
|
124
|
+
|
125
|
+
def ensure_initialized
|
126
|
+
return if @initialize_called || @session_id
|
127
|
+
|
128
|
+
initialize_session
|
129
|
+
end
|
130
|
+
|
131
|
+
# @rbs method: String
|
132
|
+
# @rbs ?params: Hash[String | Symbol, untyped]?
|
133
|
+
# @rbs ?id: String?
|
134
|
+
# @rbs &block: ? (Hash[String, untyped]) -> void
|
135
|
+
# @rbs return: Array[Hash[String, untyped]]
|
136
|
+
def send_request(method:, params: nil, id: nil, &block)
|
137
|
+
uri = URI.parse(@base_url)
|
138
|
+
|
139
|
+
raise 'Invalid uri' if uri.host.nil?
|
140
|
+
|
141
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
142
|
+
http.use_ssl = uri.scheme == 'https'
|
143
|
+
|
144
|
+
request = Net::HTTP::Post.new(uri.path.nil? || uri.path.empty? ? '/' : uri.path)
|
145
|
+
request['Accept'] = 'application/json, text/event-stream'
|
146
|
+
request['Content-Type'] = 'application/json'
|
147
|
+
request['Mcp-Session-Id'] = @session_id if @session_id
|
148
|
+
headers.each { |k, v| request[k] = v }
|
149
|
+
|
150
|
+
body = {
|
151
|
+
jsonrpc: '2.0',
|
152
|
+
method: method,
|
153
|
+
id: id || SecureRandom.uuid
|
154
|
+
} #: Hash[Symbol, untyped]
|
155
|
+
body[:params] = params if params
|
156
|
+
|
157
|
+
request.body = JSON.generate(body)
|
158
|
+
|
159
|
+
response = http.request(request)
|
160
|
+
raise Error, "HTTP #{response.code}: #{response.body}" unless response.code.to_i == 200
|
161
|
+
|
162
|
+
@session_id = response['Mcp-Session-Id'] if method == 'initialize'
|
163
|
+
|
164
|
+
if response['Content-Type'] =~ %r{text/event-stream}
|
165
|
+
handle_streaming_response(response, &block)
|
166
|
+
else
|
167
|
+
handle_response(response, &block)
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# @rbs response: Net::HTTPResponse
|
172
|
+
# @rbs &block: ? (Hash[String, untyped]) -> void
|
173
|
+
# @rbs return: Array[Hash[String, untyped]]
|
174
|
+
def handle_response(response, &block)
|
175
|
+
result = JSON.parse(response.body)
|
176
|
+
|
177
|
+
raise Error, "JSON-RPC Error #{result['error']['code']}: #{result['error']['message']}" if result['error']
|
178
|
+
|
179
|
+
result['Mcp-Session-Id'] = response['Mcp-Session-Id'] if response['Mcp-Session-Id']
|
180
|
+
|
181
|
+
block&.call(result)
|
182
|
+
|
183
|
+
[result]
|
184
|
+
end
|
185
|
+
|
186
|
+
# @rbs response: Net::HTTPResponse
|
187
|
+
# @rbs &block: ? (Hash[String, untyped]) -> void
|
188
|
+
# @rbs return: Array[Hash[String, untyped]]
|
189
|
+
def handle_streaming_response(response, &block)
|
190
|
+
events = [] #: Array[Hash[String, untyped]]
|
191
|
+
parser = EventStreamParser::Parser.new
|
192
|
+
|
193
|
+
body = response.body
|
194
|
+
parser.feed(body) do |_type, data, _id, _reconnection_time|
|
195
|
+
next if data.nil? || data.empty?
|
196
|
+
|
197
|
+
begin
|
198
|
+
parsed_data = JSON.parse(data)
|
199
|
+
raise Error, "JSON-RPC Error #{parsed_data['error']['code']}: #{parsed_data['error']['message']}" if parsed_data['error']
|
200
|
+
|
201
|
+
block&.call(parsed_data)
|
202
|
+
|
203
|
+
events << parsed_data
|
204
|
+
rescue JSON::ParserError => e
|
205
|
+
raise Error, "Failed to parse SSE data: #{e.message}"
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
events
|
210
|
+
end
|
211
|
+
|
212
|
+
class Error < StandardError; end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ruboty
|
4
|
+
module AiAgent
|
5
|
+
module LLM
|
6
|
+
class OpenAI
|
7
|
+
# Model information and token limit estimation
|
8
|
+
class Model
|
9
|
+
attr_reader :name #: String
|
10
|
+
|
11
|
+
# @rbs name: String
|
12
|
+
def initialize(name)
|
13
|
+
@name = name
|
14
|
+
end
|
15
|
+
|
16
|
+
# Estimate token limit based on model name
|
17
|
+
# @rbs return: Integer
|
18
|
+
def token_limit
|
19
|
+
if name.include?('gpt-5')
|
20
|
+
400_000
|
21
|
+
else
|
22
|
+
128_000
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,181 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'openai'
|
4
|
+
|
5
|
+
module Ruboty
|
6
|
+
module AiAgent
|
7
|
+
module LLM
|
8
|
+
# LLM interface for OpenAI's chat completion API.
|
9
|
+
class OpenAI
|
10
|
+
autoload :Model, 'ruboty/ai_agent/llm/openai/model'
|
11
|
+
attr_reader :client #: OpenAI::Client
|
12
|
+
attr_reader :model #: String
|
13
|
+
|
14
|
+
# @rbs client: OpenAI::Client
|
15
|
+
# @rbs model: String
|
16
|
+
def initialize(client:, model:)
|
17
|
+
@client = client
|
18
|
+
@model = model
|
19
|
+
end
|
20
|
+
|
21
|
+
# @rbs %a{memorized}
|
22
|
+
def model_info #: Model
|
23
|
+
@model_info ||= Model.new(model)
|
24
|
+
end
|
25
|
+
|
26
|
+
# @rbs messages: Array[ChatMessage]
|
27
|
+
# @rbs tools: Array[Tool]
|
28
|
+
# @rbs return: Response
|
29
|
+
def complete(messages:, tools: [])
|
30
|
+
openai_response = client.chat.completions.create(
|
31
|
+
model:,
|
32
|
+
messages: openai_messages_from_messages(messages), #: untyped
|
33
|
+
tools: openai_tools_from_tools(tools)
|
34
|
+
)
|
35
|
+
|
36
|
+
to_response(openai_response:, tools:)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
# @rbs!
|
42
|
+
# type tool_call = {
|
43
|
+
# id: String,
|
44
|
+
# type: 'function',
|
45
|
+
# function: { name: String, arguments: String }
|
46
|
+
# }
|
47
|
+
|
48
|
+
# @rbs!
|
49
|
+
# type system_message = { role: 'system', content: String }
|
50
|
+
# type user_message = { role: 'user', content: String }
|
51
|
+
# type assistant_message = { role: 'assistant', content: String, tool_calls: Array[tool_call]? }
|
52
|
+
# type tool_message = { role: 'tool', tool_call_id: String, content: String }
|
53
|
+
# type message = system_message | user_message | assistant_message | tool_message
|
54
|
+
|
55
|
+
# @rbs messages: Array[ChatMessage]
|
56
|
+
# @rbs return: Array[OpenAI::Models::Chat::chat_completion_message_param]
|
57
|
+
def openai_messages_from_messages(messages)
|
58
|
+
messages.map do |message|
|
59
|
+
case message.role.to_sym
|
60
|
+
when :system
|
61
|
+
::OpenAI::Models::Chat::ChatCompletionSystemMessageParam.new(
|
62
|
+
role: :system, content: message.content
|
63
|
+
)
|
64
|
+
when :user
|
65
|
+
::OpenAI::Models::Chat::ChatCompletionUserMessageParam.new(
|
66
|
+
role: :user, content: message.content
|
67
|
+
)
|
68
|
+
# { role: 'user', content: message.content } #: user_message
|
69
|
+
when :assistant
|
70
|
+
if message.tool_call_id
|
71
|
+
tool_calls = [
|
72
|
+
::OpenAI::Models::Chat::ChatCompletionMessageFunctionToolCall.new(
|
73
|
+
id: message.tool_call_id,
|
74
|
+
type: :function,
|
75
|
+
function: ::OpenAI::Models::Chat::ChatCompletionMessageFunctionToolCall::Function.new(
|
76
|
+
name: message.tool_name || 'unknown_tool',
|
77
|
+
arguments: message.tool_arguments.to_json
|
78
|
+
)
|
79
|
+
)
|
80
|
+
]
|
81
|
+
end
|
82
|
+
|
83
|
+
if tool_calls
|
84
|
+
::OpenAI::Models::Chat::ChatCompletionAssistantMessageParam.new(
|
85
|
+
role: :assistant,
|
86
|
+
content: message.content,
|
87
|
+
tool_calls: tool_calls
|
88
|
+
)
|
89
|
+
else
|
90
|
+
::OpenAI::Models::Chat::ChatCompletionAssistantMessageParam.new(
|
91
|
+
role: :assistant,
|
92
|
+
content: message.content
|
93
|
+
)
|
94
|
+
end
|
95
|
+
when :tool
|
96
|
+
::OpenAI::Models::Chat::ChatCompletionToolMessageParam.new(
|
97
|
+
role: :tool,
|
98
|
+
tool_call_id: message.tool_call_id || 'unknown_tool_call_id',
|
99
|
+
content: message.content
|
100
|
+
)
|
101
|
+
else
|
102
|
+
raise "Unknown message role: #{message.role}"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# @rbs tools: Array[Tool]
|
108
|
+
# @rbs return: Array[OpenAI::Models::Chat::chat_completion_tool]
|
109
|
+
def openai_tools_from_tools(tools)
|
110
|
+
tools.map do |tool|
|
111
|
+
::OpenAI::Models::Chat::ChatCompletionFunctionTool.new(
|
112
|
+
type: :function,
|
113
|
+
function: ::OpenAI::FunctionDefinition.new(
|
114
|
+
name: tool.name,
|
115
|
+
description: tool.description,
|
116
|
+
parameters: tool.input_schema || {
|
117
|
+
type: 'object',
|
118
|
+
properties: {}, #: Hash[untyped, untyped]
|
119
|
+
required: [] #: Array[untyped]
|
120
|
+
}
|
121
|
+
)
|
122
|
+
)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
# @rbs openai_response: OpenAI::Models::Chat::ChatCompletion
|
127
|
+
# @rbs tools: Array[Tool]
|
128
|
+
# @rbs return: Response
|
129
|
+
def to_response(openai_response:, tools:)
|
130
|
+
choice = openai_response.choices.first
|
131
|
+
tool_call = choice.message.tool_calls&.first
|
132
|
+
|
133
|
+
token_usage = if openai_response.usage
|
134
|
+
TokenUsage.new(
|
135
|
+
prompt_tokens: openai_response.usage.prompt_tokens,
|
136
|
+
completion_tokens: openai_response.usage.completion_tokens,
|
137
|
+
total_tokens: openai_response.usage.total_tokens,
|
138
|
+
token_limit: model_info.token_limit
|
139
|
+
)
|
140
|
+
end
|
141
|
+
|
142
|
+
if tool_call
|
143
|
+
tool = tools.find do |t|
|
144
|
+
if tool_call.type == :function
|
145
|
+
function_name =
|
146
|
+
tool_call #: OpenAI::Models::Chat::ChatCompletionMessageFunctionToolCall
|
147
|
+
.function.name
|
148
|
+
t.name == function_name
|
149
|
+
else
|
150
|
+
false
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
tool_arguments =
|
156
|
+
if tool_call && tool_call.type == :function
|
157
|
+
arguments =
|
158
|
+
tool_call #: OpenAI::Models::Chat::ChatCompletionMessageFunctionToolCall
|
159
|
+
.function.arguments
|
160
|
+
|
161
|
+
JSON.parse(arguments, { symbolize_names: true })
|
162
|
+
end
|
163
|
+
|
164
|
+
Response.new(
|
165
|
+
message: ChatMessage.new(
|
166
|
+
role: choice.message.role,
|
167
|
+
content: choice.message.content || '',
|
168
|
+
tool_call_id: tool_call&.id,
|
169
|
+
tool_name: tool&.name,
|
170
|
+
tool_arguments:,
|
171
|
+
token_usage:
|
172
|
+
),
|
173
|
+
tool:,
|
174
|
+
tool_call_id: tool_call&.id,
|
175
|
+
tool_arguments:
|
176
|
+
)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ruboty
|
4
|
+
module AiAgent
|
5
|
+
module LLM
|
6
|
+
Response = Data.define(
|
7
|
+
:message, #: ChatMessage
|
8
|
+
:tool, #: Ruboty::AiAgent::Tool?
|
9
|
+
:tool_call_id, #: String?
|
10
|
+
:tool_arguments #: Hash[String, String]?
|
11
|
+
)
|
12
|
+
|
13
|
+
# General response class for LLM interactions.
|
14
|
+
class Response
|
15
|
+
def call_tool #: String?
|
16
|
+
tool&.call(tool_arguments)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ruboty
|
4
|
+
module AiAgent
|
5
|
+
# Manages multiple MCP (Model Context Protocol) clients
|
6
|
+
class McpClients
|
7
|
+
attr_reader :clients #: Array[UserMcpClient]
|
8
|
+
|
9
|
+
# @rbs clients: Array[UserMcpClient]
|
10
|
+
def initialize(clients)
|
11
|
+
@clients = clients
|
12
|
+
end
|
13
|
+
|
14
|
+
# @rbs return: Array[Tool]
|
15
|
+
def available_tools
|
16
|
+
clients.flat_map do |client|
|
17
|
+
tool_defs = client.list_tools
|
18
|
+
tool_defs.map do |tool_def|
|
19
|
+
Tool.new(
|
20
|
+
name: tool_def['name'],
|
21
|
+
title: tool_def['title'] || '',
|
22
|
+
description: tool_def['description'] || '',
|
23
|
+
input_schema: tool_def['inputSchema']
|
24
|
+
) do |params|
|
25
|
+
client.call_tool(tool_def['name'], params).to_json
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# @rbs function_name: String
|
32
|
+
# @rbs arguments: Hash[String, untyped]
|
33
|
+
# @rbs return: untyped
|
34
|
+
def execute_tool(function_name, arguments)
|
35
|
+
clients.each do |mcp_client|
|
36
|
+
tools = mcp_client.list_tools
|
37
|
+
return mcp_client.call_tool(function_name, arguments) if tools.any? { |t| t['name'] == function_name }
|
38
|
+
end
|
39
|
+
nil
|
40
|
+
end
|
41
|
+
|
42
|
+
# @rbs return: bool
|
43
|
+
def any?
|
44
|
+
@clients.any?
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|