ruby-utcp 1.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 (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE +22 -0
  4. data/Makefile +226 -0
  5. data/README.md +331 -0
  6. data/examples/basic.rb +33 -0
  7. data/examples/cli.rb +32 -0
  8. data/examples/generated/__init__.py +1 -0
  9. data/examples/generated/utcp_pb2.py +46 -0
  10. data/examples/generated/utcp_pb2_grpc.py +183 -0
  11. data/examples/graphql.rb +15 -0
  12. data/examples/grpc.rb +42 -0
  13. data/examples/grpc_python.py +52 -0
  14. data/examples/http.rb +17 -0
  15. data/examples/mcp.rb +28 -0
  16. data/examples/servers/graphql_server.rb +39 -0
  17. data/examples/servers/grpc_server.py +97 -0
  18. data/examples/servers/grpc_server.rb +62 -0
  19. data/examples/servers/http_helpers.rb +34 -0
  20. data/examples/servers/http_server.rb +28 -0
  21. data/examples/servers/mcp_stdio_server.rb +43 -0
  22. data/examples/servers/requirements-grpc.txt +2 -0
  23. data/examples/servers/sse_server.rb +36 -0
  24. data/examples/servers/streamable_http_server.rb +39 -0
  25. data/examples/servers/tcp_server.rb +58 -0
  26. data/examples/servers/udp_server.rb +33 -0
  27. data/examples/servers/webrtc_server.rb +78 -0
  28. data/examples/servers/websocket_server.rb +92 -0
  29. data/examples/sse.rb +16 -0
  30. data/examples/streamable_http.rb +17 -0
  31. data/examples/tcp.rb +20 -0
  32. data/examples/text.rb +23 -0
  33. data/examples/udp.rb +18 -0
  34. data/examples/webrtc.rb +19 -0
  35. data/examples/websocket.rb +17 -0
  36. data/lib/ruby-utcp.rb +4 -0
  37. data/lib/utcp/client.rb +217 -0
  38. data/lib/utcp/config.rb +79 -0
  39. data/lib/utcp/errors.rb +48 -0
  40. data/lib/utcp/migration.rb +88 -0
  41. data/lib/utcp/models.rb +794 -0
  42. data/lib/utcp/openapi_converter.rb +179 -0
  43. data/lib/utcp/protocols/base.rb +97 -0
  44. data/lib/utcp/protocols/cli.rb +186 -0
  45. data/lib/utcp/protocols/file.rb +52 -0
  46. data/lib/utcp/protocols/graphql.rb +277 -0
  47. data/lib/utcp/protocols/grpc.rb +207 -0
  48. data/lib/utcp/protocols/http.rb +340 -0
  49. data/lib/utcp/protocols/http_stream_support.rb +122 -0
  50. data/lib/utcp/protocols/mcp.rb +339 -0
  51. data/lib/utcp/protocols/socket_support.rb +51 -0
  52. data/lib/utcp/protocols/sse.rb +107 -0
  53. data/lib/utcp/protocols/streamable_http.rb +78 -0
  54. data/lib/utcp/protocols/tcp.rb +143 -0
  55. data/lib/utcp/protocols/text.rb +44 -0
  56. data/lib/utcp/protocols/udp.rb +61 -0
  57. data/lib/utcp/protocols/webrtc.rb +217 -0
  58. data/lib/utcp/protocols/websocket.rb +350 -0
  59. data/lib/utcp/registry.rb +67 -0
  60. data/lib/utcp/repository.rb +137 -0
  61. data/lib/utcp/serializer.rb +71 -0
  62. data/lib/utcp/utils.rb +118 -0
  63. data/lib/utcp/variables.rb +170 -0
  64. data/lib/utcp/version.rb +6 -0
  65. data/lib/utcp.rb +70 -0
  66. data/proto/utcp.proto +31 -0
  67. metadata +148 -0
@@ -0,0 +1,350 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest/sha1"
5
+ require "openssl"
6
+ require "socket"
7
+ require "uri"
8
+
9
+ module UTCP
10
+ module WebSocketURLSecurity
11
+ module_function
12
+
13
+ def validate!(url)
14
+ uri = URI.parse(url.to_s)
15
+ raise SecurityError, "WebSocket URL must use ws or wss" unless %w[ws wss].include?(uri.scheme)
16
+ raise SecurityError, "WebSocket URL must contain a host" if uri.host.nil? || uri.host.empty?
17
+ raise SecurityError, "WebSocket URL must not contain user information" if uri.userinfo
18
+ if uri.scheme == "ws" && !URLSecurity.loopback_host?(uri.host)
19
+ raise SecurityError, "plain WebSocket is allowed only for loopback hosts"
20
+ end
21
+ uri
22
+ rescue URI::InvalidURIError => error
23
+ raise SecurityError, "Invalid WebSocket URL: #{error.message}"
24
+ end
25
+ end
26
+
27
+ class WebSocketConnection
28
+ GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
29
+ MAX_HEADER_SIZE = 65_536
30
+ MAX_MESSAGE_SIZE = 16 * 1024 * 1024
31
+
32
+ def initialize(url, headers = {}, protocol = nil, timeout = 30)
33
+ @uri = WebSocketURLSecurity.validate!(url)
34
+ @timeout = Float(timeout)
35
+ @read_buffer = +"".b
36
+ @closed = false
37
+ open_socket
38
+ handshake(headers, protocol)
39
+ end
40
+
41
+ def closed?
42
+ @closed || @socket.closed?
43
+ end
44
+
45
+ def send_text(value)
46
+ send_frame(0x1, value.to_s.encode(Encoding::UTF_8))
47
+ end
48
+
49
+ def send_binary(value)
50
+ send_frame(0x2, value.to_s.b)
51
+ end
52
+
53
+ def read_message
54
+ message = +"".b
55
+ message_opcode = nil
56
+ loop do
57
+ fin, opcode, payload = read_frame
58
+ case opcode
59
+ when 0x0
60
+ raise ToolCallError, "unexpected WebSocket continuation frame" unless message_opcode
61
+ message << payload
62
+ when 0x1, 0x2
63
+ raise ToolCallError, "interleaved WebSocket data frame" if message_opcode
64
+ message_opcode = opcode
65
+ message << payload
66
+ when 0x8
67
+ send_frame(0x8, payload) unless @closed
68
+ @closed = true
69
+ return nil
70
+ when 0x9
71
+ send_frame(0xA, payload)
72
+ next
73
+ when 0xA
74
+ next
75
+ else
76
+ raise ToolCallError, "unsupported WebSocket opcode #{opcode}"
77
+ end
78
+ raise ToolCallError, "WebSocket message exceeds #{MAX_MESSAGE_SIZE} bytes" if message.bytesize > MAX_MESSAGE_SIZE
79
+ return [message_opcode, message] if fin
80
+ end
81
+ end
82
+
83
+ def close
84
+ send_frame(0x8, [1000].pack("n")) unless closed?
85
+ rescue IOError, SystemCallError
86
+ nil
87
+ ensure
88
+ @closed = true
89
+ @socket.close if @socket && !@socket.closed?
90
+ end
91
+
92
+ private
93
+
94
+ def open_socket
95
+ tcp = Socket.tcp(@uri.host, websocket_port, connect_timeout: @timeout)
96
+ if @uri.scheme == "wss"
97
+ context = OpenSSL::SSL::SSLContext.new
98
+ context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
99
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
100
+ ssl.hostname = @uri.host if ssl.respond_to?(:hostname=)
101
+ ssl.sync_close = true
102
+ ssl.connect
103
+ @socket = ssl
104
+ else
105
+ @socket = tcp
106
+ end
107
+ rescue StandardError
108
+ tcp.close if tcp && !tcp.closed?
109
+ raise
110
+ end
111
+
112
+ def handshake(headers, protocol)
113
+ key = Base64.strict_encode64(Random.new.bytes(16))
114
+ path = websocket_request_target
115
+ host = @uri.host.include?(":") ? "[#{@uri.host}]" : @uri.host
116
+ default_port = @uri.scheme == "wss" ? 443 : 80
117
+ host = "#{host}:#{websocket_port}" unless websocket_port == default_port
118
+ values = {
119
+ "Host" => host,
120
+ "Upgrade" => "websocket",
121
+ "Connection" => "Upgrade",
122
+ "Sec-WebSocket-Key" => key,
123
+ "Sec-WebSocket-Version" => "13"
124
+ }
125
+ values["Sec-WebSocket-Protocol"] = protocol if protocol
126
+ reserved = %w[host upgrade connection sec-websocket-key sec-websocket-version sec-websocket-protocol]
127
+ Utils.stringify_keys(headers || {}).each do |name, value|
128
+ raise SecurityError, "WebSocket header #{name.inspect} is reserved" if reserved.include?(name.downcase)
129
+
130
+ values[name] = value.to_s
131
+ end
132
+ values.each do |name, value|
133
+ raise SecurityError, "WebSocket header contains CR/LF" if name.to_s.match?(/[\r\n]/) || value.match?(/[\r\n]/)
134
+ end
135
+ request = "GET #{path} HTTP/1.1\r\n" + values.map { |name, value| "#{name}: #{value}\r\n" }.join + "\r\n"
136
+ @socket.write(request)
137
+ header_text = read_headers
138
+ lines = header_text.split("\r\n")
139
+ status = lines.shift.to_s.split[1].to_i
140
+ raise ToolCallError, "WebSocket handshake failed with status #{status}" unless status == 101
141
+
142
+ response_headers = lines.each_with_object({}) do |line, result|
143
+ name, value = line.split(":", 2)
144
+ result[name.to_s.downcase] = value.to_s.strip
145
+ end
146
+ expected = Base64.strict_encode64(Digest::SHA1.digest(key + GUID))
147
+ unless secure_compare(response_headers["sec-websocket-accept"].to_s, expected)
148
+ raise SecurityError, "WebSocket handshake returned an invalid Sec-WebSocket-Accept"
149
+ end
150
+ unless response_headers["upgrade"].to_s.casecmp?("websocket") &&
151
+ response_headers["connection"].to_s.downcase.split(/\s*,\s*/).include?("upgrade")
152
+ raise ToolCallError, "WebSocket handshake did not confirm the protocol upgrade"
153
+ end
154
+ if protocol && response_headers["sec-websocket-protocol"] != protocol
155
+ raise ToolCallError, "WebSocket server did not select the requested subprotocol"
156
+ end
157
+ end
158
+
159
+ def websocket_port
160
+ @uri.port || (@uri.scheme == "wss" ? 443 : 80)
161
+ end
162
+
163
+ def websocket_request_target
164
+ path = @uri.path.to_s
165
+ path = "/" if path.empty?
166
+ @uri.query ? "#{path}?#{@uri.query}" : path
167
+ end
168
+
169
+ def read_headers
170
+ until (index = @read_buffer.index("\r\n\r\n"))
171
+ raise ToolCallError, "WebSocket handshake headers are too large" if @read_buffer.bytesize >= MAX_HEADER_SIZE
172
+ wait_readable
173
+ @read_buffer << @socket.readpartial(4096)
174
+ end
175
+ @read_buffer.slice!(0, index + 4).byteslice(0, index)
176
+ end
177
+
178
+ def send_frame(opcode, payload)
179
+ raise IOError, "WebSocket is closed" if closed?
180
+
181
+ bytes = payload.to_s.b
182
+ mask = Random.new.bytes(4)
183
+ header = [0x80 | opcode].pack("C")
184
+ header << if bytes.bytesize < 126
185
+ [0x80 | bytes.bytesize].pack("C")
186
+ elsif bytes.bytesize <= 65_535
187
+ [0x80 | 126, bytes.bytesize].pack("Cn")
188
+ else
189
+ [0x80 | 127, bytes.bytesize].pack("CQ>")
190
+ end
191
+ masked = bytes.bytes.each_with_index.map { |byte, index| byte ^ mask.getbyte(index % 4) }.pack("C*")
192
+ @socket.write(header + mask + masked)
193
+ end
194
+
195
+ def read_frame
196
+ head = read_exact(2)
197
+ first, second = head.unpack("CC")
198
+ fin = (first & 0x80) != 0
199
+ opcode = first & 0x0F
200
+ masked = (second & 0x80) != 0
201
+ length = second & 0x7F
202
+ length = read_exact(2).unpack1("n") if length == 126
203
+ length = read_exact(8).unpack1("Q>") if length == 127
204
+ raise ToolCallError, "WebSocket frame exceeds #{MAX_MESSAGE_SIZE} bytes" if length > MAX_MESSAGE_SIZE
205
+
206
+ mask = masked ? read_exact(4) : nil
207
+ payload = read_exact(length)
208
+ if mask
209
+ payload = payload.bytes.each_with_index.map { |byte, index| byte ^ mask.getbyte(index % 4) }.pack("C*")
210
+ end
211
+ [fin, opcode, payload]
212
+ end
213
+
214
+ def read_exact(length)
215
+ while @read_buffer.bytesize < length
216
+ wait_readable
217
+ @read_buffer << @socket.readpartial([4096, length - @read_buffer.bytesize].max)
218
+ end
219
+ @read_buffer.slice!(0, length)
220
+ rescue EOFError
221
+ @closed = true
222
+ raise ToolCallError, "WebSocket connection closed unexpectedly"
223
+ end
224
+
225
+ def wait_readable
226
+ raise TimeoutError, "WebSocket read timed out" unless IO.select([@socket], nil, nil, @timeout)
227
+ end
228
+
229
+ def secure_compare(first, second)
230
+ return false unless first.bytesize == second.bytesize
231
+
232
+ first.bytes.zip(second.bytes).reduce(0) { |memo, pair| memo | (pair[0] ^ pair[1]) }.zero?
233
+ end
234
+ end
235
+
236
+ class WebSocketProtocol < HTTPProtocol
237
+ ConnectionEntry = Struct.new(:connection, :mutex)
238
+
239
+ def initialize(connection_factory: nil, **options)
240
+ super(**options)
241
+ @connection_factory = connection_factory || lambda do |url, headers, protocol, timeout|
242
+ WebSocketConnection.new(url, headers, protocol, timeout)
243
+ end
244
+ @connections = {}
245
+ @connections_mutex = Mutex.new
246
+ end
247
+
248
+ def register_manual(client, template)
249
+ assert_websocket_template!(template)
250
+ entry, transient = connection_for(template, {})
251
+ payload = entry.mutex.synchronize do
252
+ entry.connection.send_text(JSON.generate("type" => "utcp"))
253
+ _opcode, bytes = entry.connection.read_message
254
+ bytes
255
+ end
256
+ entry.connection.close if transient
257
+ success(template, manual_from_payload(template, payload, source: "WebSocket discovery response"))
258
+ rescue StandardError => error
259
+ client.logger.warn("Unable to register WebSocket manual #{template.name.inspect}: #{error.message}")
260
+ failure(template, error)
261
+ end
262
+
263
+ def deregister_manual(_client, template)
264
+ prefix = [template.url, template.protocol].join("\0") + "\0"
265
+ entries = @connections_mutex.synchronize do
266
+ keys = @connections.keys.select { |key| key.start_with?(prefix) }
267
+ keys.map { |key| @connections.delete(key) }
268
+ end
269
+ entries.each { |entry| entry.connection.close }
270
+ nil
271
+ end
272
+
273
+ def call_tool(_client, tool_name, tool_args, template)
274
+ assert_websocket_template!(template)
275
+ args = Utils.stringify_keys(tool_args || {})
276
+ entry, transient, message_args = connection_for(template, args, include_arguments: true)
277
+ result = entry.mutex.synchronize do
278
+ message = format_message(template, message_args)
279
+ entry.connection.send_text(message)
280
+ frame = entry.connection.read_message
281
+ raise ToolCallError, "WebSocket closed without a response" unless frame
282
+
283
+ decode_message(frame[1], template.response_format, frame[0])
284
+ end
285
+ result
286
+ rescue Error
287
+ raise
288
+ rescue StandardError => error
289
+ raise ToolCallError.new("WebSocket tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
290
+ ensure
291
+ entry.connection.close if defined?(entry) && entry && defined?(transient) && transient
292
+ end
293
+
294
+ private
295
+
296
+ def assert_websocket_template!(template)
297
+ return if template.is_a?(WebSocketCallTemplate)
298
+
299
+ raise ValidationError, "WebSocket protocol requires a WebSocketCallTemplate"
300
+ end
301
+
302
+ def connection_for(template, arguments, include_arguments: false)
303
+ args = arguments.dup
304
+ headers = Utils.stringify_keys(template.headers || {})
305
+ template.header_fields.each { |field| headers[field] = args.delete(field).to_s if args.key?(field) }
306
+ query = {}
307
+ cookies = {}
308
+ apply_auth(template.auth, headers, query, cookies)
309
+ if template.auth.is_a?(OAuth2Auth)
310
+ headers["Authorization"] = "Bearer #{oauth_token(template.auth)}"
311
+ end
312
+ headers["Cookie"] = cookies.map { |key, value| "#{key}=#{value}" }.join("; ") unless cookies.empty?
313
+ url = append_query(template.url, query)
314
+ WebSocketURLSecurity.validate!(url)
315
+ key = connection_key(template, url, headers)
316
+ if template.keep_alive
317
+ entry = @connections_mutex.synchronize do
318
+ current = @connections[key]
319
+ if current.nil? || current.connection.closed?
320
+ current = ConnectionEntry.new(@connection_factory.call(url, headers, template.protocol, template.timeout), Mutex.new)
321
+ @connections[key] = current
322
+ end
323
+ current
324
+ end
325
+ include_arguments ? [entry, false, args] : [entry, false]
326
+ else
327
+ entry = ConnectionEntry.new(@connection_factory.call(url, headers, template.protocol, template.timeout), Mutex.new)
328
+ include_arguments ? [entry, true, args] : [entry, true]
329
+ end
330
+ end
331
+
332
+ def connection_key(template, url = template.url, headers = template.headers)
333
+ [url, template.protocol, JSON.generate(Utils.stringify_keys(headers || {}).sort)].join("\0")
334
+ end
335
+
336
+ def format_message(template, arguments)
337
+ value = template.message.nil? ? arguments : substitute_message_template(template.message, arguments)
338
+ value.is_a?(String) ? value : JSON.generate(value)
339
+ end
340
+
341
+ def decode_message(bytes, response_format, opcode)
342
+ return bytes.b if response_format == "raw" || opcode == 0x2
343
+
344
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
345
+ response_format == "text" ? text : decode_json_or_text(text)
346
+ end
347
+ end
348
+ WebsocketCommunicationProtocol = WebSocketProtocol
349
+ WebSocketCommunicationProtocol = WebSocketProtocol
350
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+
5
+ module UTCP
6
+ @registry_mutex = Mutex.new
7
+ @call_template_classes = {}
8
+ @auth_classes = {}
9
+ @protocols = {}
10
+
11
+ class << self
12
+ def register_call_template(type, klass)
13
+ validate_plugin_type!(type)
14
+ unless klass.respond_to?(:new)
15
+ raise ArgumentError, "call template implementation must be a class-like object"
16
+ end
17
+ @registry_mutex.synchronize { @call_template_classes[type.to_s] = klass }
18
+ klass
19
+ end
20
+
21
+ def call_template_class(type)
22
+ @registry_mutex.synchronize { @call_template_classes[type.to_s] }
23
+ end
24
+
25
+ def call_template_types
26
+ @registry_mutex.synchronize { @call_template_classes.keys.sort }
27
+ end
28
+
29
+ def register_auth(type, klass)
30
+ validate_plugin_type!(type)
31
+ @registry_mutex.synchronize { @auth_classes[type.to_s] = klass }
32
+ klass
33
+ end
34
+
35
+ def auth_class(type)
36
+ @registry_mutex.synchronize { @auth_classes[type.to_s] }
37
+ end
38
+
39
+ def register_protocol(type, implementation)
40
+ validate_plugin_type!(type)
41
+ protocol = implementation.is_a?(Class) ? implementation.new : implementation
42
+ required = %i[register_manual deregister_manual call_tool]
43
+ missing = required.reject { |method_name| protocol.respond_to?(method_name) }
44
+ raise ArgumentError, "protocol is missing: #{missing.join(', ')}" unless missing.empty?
45
+
46
+ @registry_mutex.synchronize { @protocols[type.to_s] = protocol }
47
+ protocol
48
+ end
49
+
50
+ def protocol(type)
51
+ @registry_mutex.synchronize { @protocols[type.to_s] }
52
+ end
53
+
54
+ def protocol_types
55
+ @registry_mutex.synchronize { @protocols.keys.sort }
56
+ end
57
+
58
+ private
59
+
60
+ def validate_plugin_type!(type)
61
+ unless type.to_s.match?(/\A[a-zA-Z0-9_]+\z/)
62
+ raise ArgumentError, "plugin type must contain only letters, numbers, and underscores"
63
+ end
64
+ end
65
+ end
66
+ end
67
+
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ class InMemoryToolRepository
5
+ attr_reader :tool_repository_type
6
+
7
+ def initialize
8
+ @tool_repository_type = "in_memory"
9
+ @tools = {}
10
+ @manuals = {}
11
+ @templates = {}
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def save_manual(manual_call_template, manual)
16
+ @mutex.synchronize do
17
+ old_manual = @manuals[manual_call_template.name]
18
+ old_manual&.tools&.each { |tool| @tools.delete(tool.name) }
19
+
20
+ @templates[manual_call_template.name] = manual_call_template
21
+ @manuals[manual_call_template.name] = manual
22
+ manual.tools.each { |tool| @tools[tool.name] = tool }
23
+ end
24
+ nil
25
+ end
26
+
27
+ def remove_manual(manual_name)
28
+ @mutex.synchronize do
29
+ manual = @manuals.delete(manual_name.to_s)
30
+ return false unless manual
31
+
32
+ manual.tools.each { |tool| @tools.delete(tool.name) }
33
+ @templates.delete(manual_name.to_s)
34
+ true
35
+ end
36
+ end
37
+
38
+ def remove_tool(tool_name)
39
+ @mutex.synchronize do
40
+ tool = @tools.delete(tool_name.to_s)
41
+ return false unless tool
42
+
43
+ @manuals.each_value { |manual| manual.tools.delete_if { |candidate| candidate.name == tool.name } }
44
+ true
45
+ end
46
+ end
47
+
48
+ def get_tool(tool_name)
49
+ @mutex.synchronize { @tools[tool_name.to_s] }
50
+ end
51
+
52
+ def get_tools
53
+ @mutex.synchronize { @tools.values.dup }
54
+ end
55
+
56
+ def get_tools_by_manual(manual_name)
57
+ @mutex.synchronize do
58
+ manual = @manuals[manual_name.to_s]
59
+ manual&.tools&.dup
60
+ end
61
+ end
62
+
63
+ def get_manual(manual_name)
64
+ @mutex.synchronize { @manuals[manual_name.to_s] }
65
+ end
66
+
67
+ def get_manuals
68
+ @mutex.synchronize { @manuals.values.dup }
69
+ end
70
+
71
+ def get_manual_call_template(manual_name)
72
+ @mutex.synchronize { @templates[manual_name.to_s] }
73
+ end
74
+
75
+ def get_manual_call_templates
76
+ @mutex.synchronize { @templates.values.dup }
77
+ end
78
+
79
+ def to_h
80
+ { "tool_repository_type" => tool_repository_type }
81
+ end
82
+ end
83
+ InMemToolRepository = InMemoryToolRepository
84
+
85
+ class TagSearchStrategy
86
+ attr_reader :tool_search_strategy_type, :description_weight, :tag_weight
87
+
88
+ def initialize(description_weight: 1, tag_weight: 3)
89
+ @tool_search_strategy_type = "tag_and_description_word_match"
90
+ @description_weight = Float(description_weight)
91
+ @tag_weight = Float(tag_weight)
92
+ end
93
+
94
+ def search_tools(tool_repository:, query:, limit: 10, any_of_tags_required: nil)
95
+ raise ArgumentError, "limit must be non-negative" if limit.negative?
96
+
97
+ query_text = query.to_s.downcase
98
+ query_words = query_text.scan(/[[:alnum:]_]+/).uniq
99
+ required_tags = Array(any_of_tags_required).map { |tag| tag.to_s.downcase }
100
+ tools = tool_repository.get_tools
101
+ unless required_tags.empty?
102
+ tools = tools.select do |tool|
103
+ (tool.tags.map(&:downcase) & required_tags).any?
104
+ end
105
+ end
106
+
107
+ scored = tools.each_with_index.map do |tool, index|
108
+ score = tool.tags.sum do |tag|
109
+ normalized = tag.downcase
110
+ if query_text.include?(normalized) || (normalized.scan(/[[:alnum:]_]+/) & query_words).any?
111
+ tag_weight
112
+ else
113
+ 0
114
+ end
115
+ end
116
+ description_words = tool.description.downcase.scan(/[[:alnum:]_]+/).uniq
117
+ score += (description_words & query_words).count { |word| word.length > 2 } * description_weight
118
+ name_words = tool.name.downcase.scan(/[[:alnum:]_]+/).uniq
119
+ score += (name_words & query_words).length * description_weight
120
+ [tool, score, index]
121
+ end
122
+
123
+ results = scored.sort_by { |_tool, score, index| [-score, index] }.map(&:first)
124
+ limit.zero? ? results : results.first(limit)
125
+ end
126
+
127
+ def to_h
128
+ {
129
+ "tool_search_strategy_type" => tool_search_strategy_type,
130
+ "description_weight" => description_weight,
131
+ "tag_weight" => tag_weight
132
+ }
133
+ end
134
+ end
135
+ TagAndDescriptionWordMatchStrategy = TagSearchStrategy
136
+ end
137
+
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ class Serializer
5
+ def initialize(model_class)
6
+ @model_class = model_class
7
+ end
8
+
9
+ def to_dict(object)
10
+ unless object.respond_to?(:to_h)
11
+ raise SerializerValidationError, "#{object.class} cannot be serialized"
12
+ end
13
+
14
+ object.to_h
15
+ end
16
+ alias to_h to_dict
17
+
18
+ def validate_dict(value)
19
+ @model_class.from_h(value)
20
+ rescue Error
21
+ raise
22
+ rescue StandardError => error
23
+ raise SerializerValidationError, error.message
24
+ end
25
+ alias from_h validate_dict
26
+
27
+ def copy(object)
28
+ validate_dict(to_dict(object))
29
+ end
30
+ end
31
+
32
+ class AuthSerializer < Serializer
33
+ def initialize
34
+ super(Auth)
35
+ end
36
+ end
37
+
38
+ class CallTemplateSerializer < Serializer
39
+ def initialize
40
+ super(CallTemplate)
41
+ end
42
+ end
43
+
44
+ class JsonSchemaSerializer < Serializer
45
+ def initialize
46
+ super(JsonSchema)
47
+ end
48
+ end
49
+ JSONSchemaSerializer = JsonSchemaSerializer
50
+
51
+ class ToolSerializer < Serializer
52
+ def initialize
53
+ super(Tool)
54
+ end
55
+ end
56
+
57
+ class ManualSerializer < Serializer
58
+ def initialize
59
+ super(Manual)
60
+ end
61
+ end
62
+ UtcpManualSerializer = ManualSerializer
63
+
64
+ class ClientConfigSerializer < Serializer
65
+ def initialize
66
+ super(ClientConfig)
67
+ end
68
+ end
69
+ UtcpClientConfigSerializer = ClientConfigSerializer
70
+ end
71
+