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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +45 -0
  5. data/AGENTS.md +22 -0
  6. data/CHANGELOG.md +3 -0
  7. data/CLAUDE.md +1 -0
  8. data/CODE_OF_CONDUCT.md +74 -0
  9. data/Gemfile +14 -0
  10. data/README.md +118 -0
  11. data/Rakefile +47 -0
  12. data/Steepfile +12 -0
  13. data/bin/console +15 -0
  14. data/bin/ruboty +34 -0
  15. data/bin/setup +8 -0
  16. data/lib/ruboty/ai_agent/actions/add_ai_command.rb +22 -0
  17. data/lib/ruboty/ai_agent/actions/add_ai_memory.rb +20 -0
  18. data/lib/ruboty/ai_agent/actions/add_mcp.rb +94 -0
  19. data/lib/ruboty/ai_agent/actions/base.rb +43 -0
  20. data/lib/ruboty/ai_agent/actions/chat.rb +64 -0
  21. data/lib/ruboty/ai_agent/actions/list_ai_commands.rb +19 -0
  22. data/lib/ruboty/ai_agent/actions/list_ai_memories.rb +18 -0
  23. data/lib/ruboty/ai_agent/actions/list_mcp.rb +18 -0
  24. data/lib/ruboty/ai_agent/actions/remove_ai_command.rb +18 -0
  25. data/lib/ruboty/ai_agent/actions/remove_ai_memory.rb +25 -0
  26. data/lib/ruboty/ai_agent/actions/remove_mcp.rb +24 -0
  27. data/lib/ruboty/ai_agent/actions/set_system_prompt.rb +31 -0
  28. data/lib/ruboty/ai_agent/actions/show_system_prompt.rb +30 -0
  29. data/lib/ruboty/ai_agent/actions.rb +22 -0
  30. data/lib/ruboty/ai_agent/agent.rb +71 -0
  31. data/lib/ruboty/ai_agent/cached_value.rb +43 -0
  32. data/lib/ruboty/ai_agent/chat_message.rb +60 -0
  33. data/lib/ruboty/ai_agent/chat_thread.rb +31 -0
  34. data/lib/ruboty/ai_agent/chat_thread_associations.rb +34 -0
  35. data/lib/ruboty/ai_agent/chat_thread_messages.rb +17 -0
  36. data/lib/ruboty/ai_agent/commands/base.rb +39 -0
  37. data/lib/ruboty/ai_agent/commands/clear.rb +29 -0
  38. data/lib/ruboty/ai_agent/commands/compact.rb +80 -0
  39. data/lib/ruboty/ai_agent/commands/usage.rb +52 -0
  40. data/lib/ruboty/ai_agent/commands.rb +33 -0
  41. data/lib/ruboty/ai_agent/database/query_methods.rb +84 -0
  42. data/lib/ruboty/ai_agent/database.rb +40 -0
  43. data/lib/ruboty/ai_agent/global_settings.rb +33 -0
  44. data/lib/ruboty/ai_agent/http_mcp_client.rb +215 -0
  45. data/lib/ruboty/ai_agent/llm/openai/model.rb +29 -0
  46. data/lib/ruboty/ai_agent/llm/openai.rb +181 -0
  47. data/lib/ruboty/ai_agent/llm/response.rb +21 -0
  48. data/lib/ruboty/ai_agent/llm.rb +11 -0
  49. data/lib/ruboty/ai_agent/mcp_clients.rb +48 -0
  50. data/lib/ruboty/ai_agent/mcp_configuration.rb +31 -0
  51. data/lib/ruboty/ai_agent/record_set.rb +71 -0
  52. data/lib/ruboty/ai_agent/recordable.rb +116 -0
  53. data/lib/ruboty/ai_agent/token_usage.rb +45 -0
  54. data/lib/ruboty/ai_agent/tool.rb +29 -0
  55. data/lib/ruboty/ai_agent/user.rb +52 -0
  56. data/lib/ruboty/ai_agent/user_ai_memories.rb +17 -0
  57. data/lib/ruboty/ai_agent/user_associations.rb +34 -0
  58. data/lib/ruboty/ai_agent/user_mcp_caches.rb +90 -0
  59. data/lib/ruboty/ai_agent/user_mcp_client.rb +93 -0
  60. data/lib/ruboty/ai_agent/user_mcp_configurations.rb +15 -0
  61. data/lib/ruboty/ai_agent/user_mcp_tools_caches.rb +14 -0
  62. data/lib/ruboty/ai_agent/version.rb +7 -0
  63. data/lib/ruboty/ai_agent.rb +40 -0
  64. data/lib/ruboty/handlers/ai_agent.rb +84 -0
  65. data/rbs_collection.yaml +23 -0
  66. data/ruboty-ai_agent.gemspec +49 -0
  67. data/script/generate-concern-rbs.rb +351 -0
  68. data/script/generate-data-rbs.rb +250 -0
  69. data/script/generate-memorized-ivar-rbs.rb +292 -0
  70. data/sig/generated/ruboty/ai_agent/actions/add_ai_command.rbs +16 -0
  71. data/sig/generated/ruboty/ai_agent/actions/add_ai_memory.rbs +14 -0
  72. data/sig/generated/ruboty/ai_agent/actions/add_mcp.rbs +26 -0
  73. data/sig/generated/ruboty/ai_agent/actions/base.rbs +34 -0
  74. data/sig/generated/ruboty/ai_agent/actions/chat.rbs +17 -0
  75. data/sig/generated/ruboty/ai_agent/actions/list_ai_commands.rbs +13 -0
  76. data/sig/generated/ruboty/ai_agent/actions/list_ai_memories.rbs +12 -0
  77. data/sig/generated/ruboty/ai_agent/actions/list_mcp.rbs +12 -0
  78. data/sig/generated/ruboty/ai_agent/actions/remove_ai_command.rbs +14 -0
  79. data/sig/generated/ruboty/ai_agent/actions/remove_ai_memory.rbs +14 -0
  80. data/sig/generated/ruboty/ai_agent/actions/remove_mcp.rbs +14 -0
  81. data/sig/generated/ruboty/ai_agent/actions/set_system_prompt.rbs +16 -0
  82. data/sig/generated/ruboty/ai_agent/actions/show_system_prompt.rbs +12 -0
  83. data/sig/generated/ruboty/ai_agent/actions.rbs +9 -0
  84. data/sig/generated/ruboty/ai_agent/agent.rbs +29 -0
  85. data/sig/generated/ruboty/ai_agent/cached_value.rbs +28 -0
  86. data/sig/generated/ruboty/ai_agent/chat_message.rbs +34 -0
  87. data/sig/generated/ruboty/ai_agent/chat_thread.rbs +22 -0
  88. data/sig/generated/ruboty/ai_agent/chat_thread_associations.rbs +21 -0
  89. data/sig/generated/ruboty/ai_agent/chat_thread_messages.rbs +13 -0
  90. data/sig/generated/ruboty/ai_agent/commands/base.rbs +40 -0
  91. data/sig/generated/ruboty/ai_agent/commands/clear.rbs +20 -0
  92. data/sig/generated/ruboty/ai_agent/commands/compact.rbs +30 -0
  93. data/sig/generated/ruboty/ai_agent/commands/usage.rbs +26 -0
  94. data/sig/generated/ruboty/ai_agent/commands.rbs +13 -0
  95. data/sig/generated/ruboty/ai_agent/database/query_methods.rbs +39 -0
  96. data/sig/generated/ruboty/ai_agent/database.rbs +27 -0
  97. data/sig/generated/ruboty/ai_agent/global_settings.rbs +23 -0
  98. data/sig/generated/ruboty/ai_agent/http_mcp_client.rbs +62 -0
  99. data/sig/generated/ruboty/ai_agent/llm/openai/model.rbs +21 -0
  100. data/sig/generated/ruboty/ai_agent/llm/openai.rbs +54 -0
  101. data/sig/generated/ruboty/ai_agent/llm/response.rbs +29 -0
  102. data/sig/generated/ruboty/ai_agent/llm.rbs +9 -0
  103. data/sig/generated/ruboty/ai_agent/mcp_clients.rbs +24 -0
  104. data/sig/generated/ruboty/ai_agent/mcp_configuration.rbs +35 -0
  105. data/sig/generated/ruboty/ai_agent/record_set.rbs +42 -0
  106. data/sig/generated/ruboty/ai_agent/recordable.rbs +56 -0
  107. data/sig/generated/ruboty/ai_agent/token_usage.rbs +30 -0
  108. data/sig/generated/ruboty/ai_agent/tool.rbs +27 -0
  109. data/sig/generated/ruboty/ai_agent/user.rbs +35 -0
  110. data/sig/generated/ruboty/ai_agent/user_ai_memories.rbs +11 -0
  111. data/sig/generated/ruboty/ai_agent/user_associations.rbs +21 -0
  112. data/sig/generated/ruboty/ai_agent/user_mcp_caches.rbs +44 -0
  113. data/sig/generated/ruboty/ai_agent/user_mcp_client.rbs +58 -0
  114. data/sig/generated/ruboty/ai_agent/user_mcp_configurations.rbs +11 -0
  115. data/sig/generated/ruboty/ai_agent/user_mcp_tools_caches.rbs +11 -0
  116. data/sig/generated/ruboty/ai_agent/version.rbs +7 -0
  117. data/sig/generated/ruboty/ai_agent.rbs +9 -0
  118. data/sig/generated/ruboty/handlers/ai_agent.rbs +32 -0
  119. data/sig/generated-by-scripts/concerns.rbs +27 -0
  120. data/sig/generated-by-scripts/memorized_ivars.rbs +42 -0
  121. data/sig-lib/event_stream_parser/event_stream_parser.rbs +21 -0
  122. data/sig-lib/mem/mem.rbs +19 -0
  123. data/sig-lib/ruboty/ruboty.rbs +421 -0
  124. 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ # LLM-related backends and its utilities.
6
+ module LLM
7
+ autoload :OpenAI, 'ruboty/ai_agent/llm/openai'
8
+ autoload :Response, 'ruboty/ai_agent/llm/response'
9
+ end
10
+ end
11
+ 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