ruby-utcp 1.1.5 → 1.1.6
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 +4 -4
- data/CHANGELOG.md +15 -1
- data/README.md +19 -2
- data/lib/utcp/models.rb +21 -3
- data/lib/utcp/openapi_converter.rb +86 -23
- data/lib/utcp/protocols/graphql.rb +9 -2
- data/lib/utcp/protocols/grpc.rb +16 -5
- data/lib/utcp/protocols/http.rb +37 -5
- data/lib/utcp/protocols/http_stream_support.rb +13 -4
- data/lib/utcp/protocols/mcp.rb +26 -17
- data/lib/utcp/protocols/sse.rb +58 -19
- data/lib/utcp/protocols/streamable_http.rb +42 -16
- data/lib/utcp/protocols/tcp.rb +21 -9
- data/lib/utcp/protocols/udp.rb +2 -0
- data/lib/utcp/protocols/webrtc.rb +107 -24
- data/lib/utcp/protocols/webrtc_native_cleanup.rb +39 -0
- data/lib/utcp/protocols/websocket.rb +19 -5
- data/lib/utcp/response_limits.rb +114 -0
- data/lib/utcp/version.rb +1 -1
- metadata +3 -1
data/lib/utcp/protocols/mcp.rb
CHANGED
|
@@ -7,12 +7,14 @@ module UTCP
|
|
|
7
7
|
MAX_MESSAGE_BYTES = 16 * 1024 * 1024
|
|
8
8
|
MAX_STDERR_BYTES = 64 * 1024
|
|
9
9
|
|
|
10
|
-
def initialize(config, timeout, max_message_bytes: MAX_MESSAGE_BYTES)
|
|
10
|
+
def initialize(config, timeout, max_message_bytes: MAX_MESSAGE_BYTES, max_response_bytes: nil)
|
|
11
11
|
@timeout = Float(timeout)
|
|
12
12
|
raise ValidationError, "MCP timeout must be finite and greater than zero" unless @timeout.finite? && @timeout.positive?
|
|
13
13
|
|
|
14
14
|
@max_message_bytes = Integer(max_message_bytes)
|
|
15
15
|
raise ValidationError, "MCP message limit must be greater than zero" unless @max_message_bytes.positive?
|
|
16
|
+
@max_response_bytes = max_response_bytes.nil? ? @max_message_bytes : Integer(max_response_bytes)
|
|
17
|
+
raise ValidationError, "MCP response limit must be greater than zero" unless @max_response_bytes.positive?
|
|
16
18
|
|
|
17
19
|
command = config["command"]
|
|
18
20
|
command = command.first if command.is_a?(Array)
|
|
@@ -41,8 +43,9 @@ module UTCP
|
|
|
41
43
|
message = { "jsonrpc" => "2.0", "id" => identifier, "method" => method }
|
|
42
44
|
message["params"] = params unless params.nil?
|
|
43
45
|
write_message(message, deadline)
|
|
46
|
+
budget = ResponseByteBudget.new(@max_response_bytes, "MCP stdio")
|
|
44
47
|
loop do
|
|
45
|
-
response = read_message(deadline)
|
|
48
|
+
response = read_message(deadline, budget)
|
|
46
49
|
next unless response["id"] == identifier
|
|
47
50
|
raise ToolCallError, "MCP error #{response["error"].inspect}" if response["error"]
|
|
48
51
|
|
|
@@ -99,20 +102,22 @@ module UTCP
|
|
|
99
102
|
raise ToolCallError, "MCP stdio write failed: #{error.message}"
|
|
100
103
|
end
|
|
101
104
|
|
|
102
|
-
def read_message(deadline)
|
|
105
|
+
def read_message(deadline, budget = ResponseByteBudget.new(@max_response_bytes, "MCP stdio"))
|
|
103
106
|
loop do
|
|
104
107
|
remaining_time(deadline)
|
|
105
108
|
if (index = @read_buffer.index("\n"))
|
|
106
|
-
|
|
109
|
+
bytes = @read_buffer.slice!(0, index + 1)
|
|
110
|
+
budget.consume(bytes)
|
|
111
|
+
response = JSON.parse(bytes)
|
|
107
112
|
raise SerializerValidationError, "MCP stdio response must be an object" unless response.is_a?(Hash)
|
|
108
113
|
|
|
109
114
|
return response
|
|
110
115
|
end
|
|
111
|
-
if @read_buffer.bytesize >=
|
|
112
|
-
raise SerializerValidationError, "MCP stdio message exceeds #{@
|
|
116
|
+
if @read_buffer.bytesize >= budget.remaining
|
|
117
|
+
raise SerializerValidationError, "MCP stdio message exceeds #{@max_response_bytes} bytes (max_response_bytes)"
|
|
113
118
|
end
|
|
114
119
|
|
|
115
|
-
chunk = @stdout.read_nonblock([4096,
|
|
120
|
+
chunk = @stdout.read_nonblock([4096, budget.remaining - @read_buffer.bytesize].min, exception: false)
|
|
116
121
|
case chunk
|
|
117
122
|
when :wait_readable then wait_for_io(deadline)
|
|
118
123
|
when nil then raise ToolCallError, "MCP stdio server closed the stream"
|
|
@@ -211,7 +216,7 @@ module UTCP
|
|
|
211
216
|
values = []
|
|
212
217
|
parser = SSEParser.new
|
|
213
218
|
parser.feed(body) { |value| values << value }
|
|
214
|
-
parser.finish
|
|
219
|
+
parser.finish
|
|
215
220
|
values
|
|
216
221
|
end
|
|
217
222
|
end
|
|
@@ -229,10 +234,11 @@ module UTCP
|
|
|
229
234
|
assert_mcp_template!(template)
|
|
230
235
|
tools = []
|
|
231
236
|
errors = []
|
|
237
|
+
budget = ResponseByteBudget.new(template.max_response_bytes, "MCP discovery")
|
|
232
238
|
template.servers.each do |server_name, config|
|
|
233
239
|
begin
|
|
234
240
|
session = session_for(client, template, server_name, config)
|
|
235
|
-
each_list_item(session, "tools/list", "tools") do |tool|
|
|
241
|
+
each_list_item(session, "tools/list", "tools", budget) do |tool|
|
|
236
242
|
tools << Tool.new(
|
|
237
243
|
name: "#{server_name}.#{tool.fetch("name")}",
|
|
238
244
|
description: tool["description"].to_s,
|
|
@@ -241,7 +247,7 @@ module UTCP
|
|
|
241
247
|
tool_call_template: template
|
|
242
248
|
)
|
|
243
249
|
end
|
|
244
|
-
add_resource_tools(client, template, server_name, session, tools) if template.register_resources_as_tools
|
|
250
|
+
add_resource_tools(client, template, server_name, session, tools, budget) if template.register_resources_as_tools
|
|
245
251
|
rescue StandardError => error
|
|
246
252
|
errors << "#{server_name}: #{error.message}"
|
|
247
253
|
end
|
|
@@ -280,6 +286,7 @@ module UTCP
|
|
|
280
286
|
else
|
|
281
287
|
session.request("tools/call", "name" => local_name, "arguments" => Utils.stringify_keys(tool_args || {}))
|
|
282
288
|
end
|
|
289
|
+
ResponseByteBudget.new(template.max_response_bytes, "MCP").consume_value(result)
|
|
283
290
|
process_mcp_result(result, tool_name: tool_name)
|
|
284
291
|
rescue Error
|
|
285
292
|
raise
|
|
@@ -308,7 +315,7 @@ module UTCP
|
|
|
308
315
|
body: message,
|
|
309
316
|
content_type: "application/json",
|
|
310
317
|
timeout: template.timeout,
|
|
311
|
-
sensitive_headers: sensitive.uniq
|
|
318
|
+
sensitive_headers: sensitive.uniq, max_response_bytes: template.max_response_bytes
|
|
312
319
|
)
|
|
313
320
|
{
|
|
314
321
|
body: response.body.to_s,
|
|
@@ -330,7 +337,7 @@ module UTCP
|
|
|
330
337
|
assert_no_auth!(template, context: "MCP stdio") unless http_transport || @session_factory
|
|
331
338
|
# A session belongs to the credentials and endpoint used to initialize it.
|
|
332
339
|
fingerprint = Digest::SHA256.hexdigest(JSON.generate([
|
|
333
|
-
config, template.auth&.to_h, template.protocol_version, template.timeout
|
|
340
|
+
config, template.auth&.to_h, template.protocol_version, template.timeout, template.max_response_bytes
|
|
334
341
|
]))
|
|
335
342
|
key = [client, template.name, server_name, fingerprint]
|
|
336
343
|
@sessions_mutex.synchronize do
|
|
@@ -343,7 +350,7 @@ module UTCP
|
|
|
343
350
|
elsif http_transport
|
|
344
351
|
MCPHTTPSession.new(server_config, snapshot, self)
|
|
345
352
|
else
|
|
346
|
-
MCPStdioSession.new(server_config, snapshot.timeout)
|
|
353
|
+
MCPStdioSession.new(server_config, snapshot.timeout, max_response_bytes: snapshot.max_response_bytes)
|
|
347
354
|
end
|
|
348
355
|
begin
|
|
349
356
|
initialize_session(session, snapshot)
|
|
@@ -356,20 +363,22 @@ module UTCP
|
|
|
356
363
|
end
|
|
357
364
|
|
|
358
365
|
def initialize_session(session, template)
|
|
359
|
-
session.request("initialize", {
|
|
366
|
+
response = session.request("initialize", {
|
|
360
367
|
"protocolVersion" => template.protocol_version,
|
|
361
368
|
"capabilities" => {},
|
|
362
369
|
"clientInfo" => { "name" => "ruby-utcp", "version" => VERSION }
|
|
363
370
|
})
|
|
371
|
+
ResponseByteBudget.new(template.max_response_bytes, "MCP initialization").consume_value(response)
|
|
364
372
|
session.notify("notifications/initialized", {})
|
|
365
373
|
end
|
|
366
374
|
|
|
367
|
-
def each_list_item(session, method, collection)
|
|
375
|
+
def each_list_item(session, method, collection, budget)
|
|
368
376
|
cursor = nil
|
|
369
377
|
seen = {}
|
|
370
378
|
loop do
|
|
371
379
|
params = cursor.nil? ? {} : { "cursor" => cursor }
|
|
372
380
|
result = Utils.hash!(session.request(method, params) || {}, "MCP #{method} result")
|
|
381
|
+
budget.consume_value(result)
|
|
373
382
|
Utils.array!(result.fetch(collection, []), "MCP #{collection}").each { |item| yield item }
|
|
374
383
|
cursor = result["nextCursor"]
|
|
375
384
|
break if cursor.nil?
|
|
@@ -381,8 +390,8 @@ module UTCP
|
|
|
381
390
|
end
|
|
382
391
|
end
|
|
383
392
|
|
|
384
|
-
def add_resource_tools(client, template, server_name, session, tools)
|
|
385
|
-
each_list_item(session, "resources/list", "resources") do |resource|
|
|
393
|
+
def add_resource_tools(client, template, server_name, session, tools, budget)
|
|
394
|
+
each_list_item(session, "resources/list", "resources", budget) do |resource|
|
|
386
395
|
safe_name = resource.fetch("name", resource.fetch("uri")).to_s.gsub(/[^[:alnum:]_]/, "_")
|
|
387
396
|
local_name = "resource_#{safe_name}"
|
|
388
397
|
@sessions_mutex.synchronize do
|
data/lib/utcp/protocols/sse.rb
CHANGED
|
@@ -16,7 +16,9 @@ module UTCP
|
|
|
16
16
|
|
|
17
17
|
def call_tool(client, tool_name, tool_args, template)
|
|
18
18
|
values = []
|
|
19
|
-
|
|
19
|
+
with_collection_timeout(template) do
|
|
20
|
+
call_tool_streaming(client, tool_name, tool_args, template) { |value| values << value }
|
|
21
|
+
end
|
|
20
22
|
values
|
|
21
23
|
end
|
|
22
24
|
|
|
@@ -25,11 +27,16 @@ module UTCP
|
|
|
25
27
|
|
|
26
28
|
assert_sse_template!(template)
|
|
27
29
|
with_stream_response(template, tool_args || {}, accept: "text/event-stream") do |response|
|
|
28
|
-
parser = SSEParser.new(event_type: template.event_type)
|
|
30
|
+
parser = SSEParser.new(event_type: template.event_type, max_event_bytes: template.max_event_bytes)
|
|
31
|
+
count = 0
|
|
29
32
|
response.read_body do |chunk|
|
|
30
|
-
parser.feed(chunk)
|
|
33
|
+
parser.feed(chunk) do |event|
|
|
34
|
+
count += 1
|
|
35
|
+
raise ToolCallError, "SSE response exceeds max_response_items" if count > template.max_response_items
|
|
36
|
+
yield event
|
|
37
|
+
end
|
|
31
38
|
end
|
|
32
|
-
parser.finish
|
|
39
|
+
parser.finish
|
|
33
40
|
end
|
|
34
41
|
rescue Error
|
|
35
42
|
raise
|
|
@@ -47,30 +54,57 @@ module UTCP
|
|
|
47
54
|
end
|
|
48
55
|
|
|
49
56
|
class SSEParser
|
|
50
|
-
def initialize(event_type: nil)
|
|
57
|
+
def initialize(event_type: nil, max_event_bytes: ResponseLimits::DEFAULT_MAX_EVENT_BYTES)
|
|
51
58
|
@event_type = event_type
|
|
52
|
-
@
|
|
59
|
+
@maximum = Integer(max_event_bytes)
|
|
60
|
+
raise ValidationError, "max_event_bytes must be greater than zero" unless @maximum.positive?
|
|
61
|
+
@buffer = +"".b
|
|
62
|
+
@skip_lf = false
|
|
63
|
+
@first_line = true
|
|
64
|
+
@event_bytes = 0
|
|
53
65
|
@fields = reset_fields
|
|
54
66
|
end
|
|
55
67
|
|
|
56
68
|
def feed(chunk)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
bytes = chunk.to_s.b
|
|
70
|
+
offset = 0
|
|
71
|
+
while offset < bytes.bytesize
|
|
72
|
+
if @skip_lf
|
|
73
|
+
@skip_lf = false
|
|
74
|
+
offset += 1 if bytes.getbyte(offset) == 10
|
|
75
|
+
next if offset == bytes.bytesize
|
|
76
|
+
end
|
|
77
|
+
index = bytes.index(/[\r\n]/, offset)
|
|
78
|
+
length = (index || bytes.bytesize) - offset
|
|
79
|
+
@event_bytes += length
|
|
80
|
+
raise ToolCallError, "SSE event exceeds max_event_bytes (#{@maximum})" if @event_bytes > @maximum
|
|
81
|
+
@buffer << bytes.byteslice(offset, length)
|
|
82
|
+
break unless index
|
|
83
|
+
|
|
84
|
+
line = @buffer
|
|
85
|
+
@buffer = +"".b
|
|
86
|
+
if @first_line
|
|
87
|
+
line = line.delete_prefix("\xEF\xBB\xBF".b)
|
|
88
|
+
@first_line = false
|
|
89
|
+
end
|
|
90
|
+
@skip_lf = bytes.getbyte(index) == 13
|
|
91
|
+
offset = index + 1
|
|
92
|
+
process_line(line.force_encoding(Encoding::UTF_8)) { |event| yield event }
|
|
61
93
|
end
|
|
62
94
|
end
|
|
63
95
|
|
|
64
96
|
def finish
|
|
65
|
-
|
|
66
|
-
dispatch { |event| yield event } unless @fields[:data].empty?
|
|
97
|
+
# EOF is not an event delimiter; discard an unfinished event.
|
|
67
98
|
@buffer.clear
|
|
99
|
+
@fields = reset_fields
|
|
100
|
+
@event_bytes = 0
|
|
68
101
|
end
|
|
69
102
|
|
|
70
103
|
private
|
|
71
104
|
|
|
72
105
|
def process_line(line)
|
|
73
106
|
if line.empty?
|
|
107
|
+
@event_bytes = 0
|
|
74
108
|
dispatch { |event| yield event }
|
|
75
109
|
return
|
|
76
110
|
end
|
|
@@ -79,7 +113,9 @@ module UTCP
|
|
|
79
113
|
field, value = line.split(":", 2)
|
|
80
114
|
value = value.to_s.sub(/\A /, "")
|
|
81
115
|
case field
|
|
82
|
-
when "data"
|
|
116
|
+
when "data"
|
|
117
|
+
@fields[:has_data] = true
|
|
118
|
+
@fields[:data] << value << "\n"
|
|
83
119
|
when "event" then @fields[:event] = value
|
|
84
120
|
when "id" then @fields[:id] = value unless value.include?("\0")
|
|
85
121
|
when "retry" then @fields[:retry] = Integer(value) rescue nil
|
|
@@ -89,17 +125,20 @@ module UTCP
|
|
|
89
125
|
def dispatch
|
|
90
126
|
fields = @fields
|
|
91
127
|
@fields = reset_fields
|
|
92
|
-
return
|
|
128
|
+
return unless fields[:has_data]
|
|
93
129
|
return if @event_type && fields[:event] != @event_type
|
|
94
130
|
|
|
95
|
-
payload = fields[:data].
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
131
|
+
payload = fields[:data].delete_suffix("\n")
|
|
132
|
+
value = begin
|
|
133
|
+
JSON.parse(payload)
|
|
134
|
+
rescue JSON::ParserError
|
|
135
|
+
payload
|
|
136
|
+
end
|
|
137
|
+
yield value
|
|
99
138
|
end
|
|
100
139
|
|
|
101
140
|
def reset_fields
|
|
102
|
-
{ data:
|
|
141
|
+
{ data: +"", has_data: false, event: nil, id: nil, retry: nil }
|
|
103
142
|
end
|
|
104
143
|
end
|
|
105
144
|
SseCommunicationProtocol = SSEProtocol
|
|
@@ -16,9 +16,11 @@ module UTCP
|
|
|
16
16
|
def call_tool(client, tool_name, tool_args, template)
|
|
17
17
|
chunks = []
|
|
18
18
|
binary = false
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
with_collection_timeout(template) do
|
|
20
|
+
call_tool_streaming(client, tool_name, tool_args, template) do |chunk|
|
|
21
|
+
binary ||= chunk.is_a?(String) && chunk.encoding == Encoding::BINARY
|
|
22
|
+
chunks << chunk
|
|
23
|
+
end
|
|
22
24
|
end
|
|
23
25
|
binary && chunks.all? { |chunk| chunk.is_a?(String) } ? chunks.join.b : chunks
|
|
24
26
|
end
|
|
@@ -27,21 +29,32 @@ module UTCP
|
|
|
27
29
|
return enum_for(__method__, _client, tool_name, tool_args, template) unless block_given?
|
|
28
30
|
|
|
29
31
|
assert_stream_template!(template)
|
|
32
|
+
count = 0
|
|
33
|
+
emit = lambda do |item|
|
|
34
|
+
count += 1
|
|
35
|
+
raise ToolCallError, "Streamable HTTP response exceeds max_response_items" if count > template.max_response_items
|
|
36
|
+
yield item
|
|
37
|
+
end
|
|
30
38
|
with_stream_response(template, tool_args || {}) do |response|
|
|
31
39
|
content_type = response["content-type"].to_s.downcase
|
|
32
40
|
if content_type.include?("application/x-ndjson") || content_type.include?("application/json-seq")
|
|
33
|
-
|
|
41
|
+
separator = content_type.include?("application/json-seq") ? "\x1E" : "\n"
|
|
42
|
+
stream_json_lines(response, template.max_event_bytes, separator, &emit)
|
|
34
43
|
elsif content_type.include?("application/json")
|
|
35
44
|
body = +""
|
|
36
|
-
response.read_body
|
|
37
|
-
|
|
45
|
+
response.read_body do |chunk|
|
|
46
|
+
check_event_size!(body.bytesize + chunk.bytesize, template.max_event_bytes)
|
|
47
|
+
body << chunk
|
|
48
|
+
end
|
|
49
|
+
emit.call(decode_json_or_text(body)) unless body.empty?
|
|
38
50
|
else
|
|
39
51
|
response.read_body do |chunk|
|
|
40
52
|
bytes = chunk.to_s.b
|
|
41
53
|
offset = 0
|
|
42
54
|
while offset < bytes.bytesize
|
|
43
|
-
|
|
44
|
-
offset
|
|
55
|
+
size = [template.chunk_size, template.max_event_bytes].min
|
|
56
|
+
emit.call(bytes.byteslice(offset, size))
|
|
57
|
+
offset += size
|
|
45
58
|
end
|
|
46
59
|
end
|
|
47
60
|
end
|
|
@@ -60,17 +73,30 @@ module UTCP
|
|
|
60
73
|
raise ValidationError, "streamable HTTP protocol requires a StreamableHttpCallTemplate"
|
|
61
74
|
end
|
|
62
75
|
|
|
63
|
-
def stream_json_lines(response)
|
|
64
|
-
buffer = +""
|
|
76
|
+
def stream_json_lines(response, maximum, separator = "\n")
|
|
77
|
+
buffer = +"".b
|
|
65
78
|
response.read_body do |chunk|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
79
|
+
bytes = chunk.to_s.b
|
|
80
|
+
offset = 0
|
|
81
|
+
while offset < bytes.bytesize
|
|
82
|
+
index = bytes.index(separator, offset)
|
|
83
|
+
length = (index || bytes.bytesize) - offset
|
|
84
|
+
check_event_size!(buffer.bytesize + length, maximum)
|
|
85
|
+
buffer << bytes.byteslice(offset, length)
|
|
86
|
+
break unless index
|
|
87
|
+
|
|
88
|
+
line = buffer.strip
|
|
89
|
+
buffer = +"".b
|
|
90
|
+
yield decode_json_or_text(line.force_encoding(Encoding::UTF_8)) unless line.empty?
|
|
91
|
+
offset = index + 1
|
|
70
92
|
end
|
|
71
93
|
end
|
|
72
|
-
tail = buffer.strip
|
|
73
|
-
yield decode_json_or_text(tail) unless tail.empty?
|
|
94
|
+
tail = buffer.strip
|
|
95
|
+
yield decode_json_or_text(tail.force_encoding(Encoding::UTF_8)) unless tail.empty?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def check_event_size!(size, maximum)
|
|
99
|
+
raise ToolCallError, "Streamable HTTP event exceeds max_event_bytes (#{maximum})" if size > maximum
|
|
74
100
|
end
|
|
75
101
|
end
|
|
76
102
|
StreamableHttpCommunicationProtocol = StreamableHTTPProtocol
|
data/lib/utcp/protocols/tcp.rb
CHANGED
|
@@ -71,6 +71,7 @@ module UTCP
|
|
|
71
71
|
|
|
72
72
|
def read_framed(socket, template, timeout)
|
|
73
73
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
74
|
+
maximum = [template.max_response_size, template.max_response_bytes].min
|
|
74
75
|
case template.framing_strategy
|
|
75
76
|
when "length_prefix"
|
|
76
77
|
prefix = read_exact(socket, template.length_prefix_bytes, deadline)
|
|
@@ -81,16 +82,19 @@ module UTCP
|
|
|
81
82
|
[8, "big"] => "Q>", [8, "little"] => "Q<"
|
|
82
83
|
}.fetch([template.length_prefix_bytes, template.length_prefix_endian])
|
|
83
84
|
length = prefix.unpack1(unpack)
|
|
84
|
-
raise ToolCallError, "TCP response exceeds max_response_size" if length >
|
|
85
|
+
raise ToolCallError, "TCP response exceeds max_response_bytes or max_response_size" if length > maximum
|
|
85
86
|
|
|
86
87
|
read_exact(socket, length, deadline)
|
|
87
88
|
when "delimiter"
|
|
88
89
|
read_until(socket, escaped_delimiter(template.message_delimiter, template.interpret_escape_sequences),
|
|
89
|
-
|
|
90
|
+
maximum, deadline)
|
|
90
91
|
when "fixed_length"
|
|
92
|
+
if template.fixed_message_length > maximum
|
|
93
|
+
raise ToolCallError, "TCP response exceeds max_response_bytes or max_response_size"
|
|
94
|
+
end
|
|
91
95
|
read_exact(socket, template.fixed_message_length, deadline)
|
|
92
96
|
when "stream"
|
|
93
|
-
read_stream(socket,
|
|
97
|
+
read_stream(socket, maximum, deadline)
|
|
94
98
|
end
|
|
95
99
|
end
|
|
96
100
|
|
|
@@ -112,25 +116,33 @@ module UTCP
|
|
|
112
116
|
raise ValidationError, "message_delimiter cannot be empty" if delimiter.empty?
|
|
113
117
|
|
|
114
118
|
result = +"".b
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
loop do
|
|
120
|
+
if (index = result.index(delimiter))
|
|
121
|
+
raise ToolCallError, "TCP response exceeds max_response_bytes or max_response_size" if index > maximum
|
|
122
|
+
return result.byteslice(0, index)
|
|
123
|
+
end
|
|
124
|
+
if result.bytesize >= maximum + delimiter.bytesize
|
|
125
|
+
raise ToolCallError, "TCP response exceeds max_response_bytes or max_response_size"
|
|
126
|
+
end
|
|
117
127
|
|
|
118
128
|
wait_readable!(socket, deadline, "TCP read")
|
|
119
|
-
result << socket.readpartial([4096, maximum - result.bytesize].min)
|
|
129
|
+
result << socket.readpartial([4096, maximum + delimiter.bytesize - result.bytesize].min)
|
|
120
130
|
end
|
|
121
|
-
result.byteslice(0, result.bytesize - delimiter.bytesize)
|
|
122
131
|
rescue EOFError
|
|
123
132
|
raise ToolCallError, "TCP connection closed before the message delimiter"
|
|
124
133
|
end
|
|
125
134
|
|
|
126
135
|
def read_stream(socket, maximum, deadline)
|
|
127
136
|
result = +"".b
|
|
128
|
-
|
|
137
|
+
loop do
|
|
129
138
|
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
130
139
|
break unless remaining.positive? && IO.select([socket], nil, nil, remaining)
|
|
131
140
|
|
|
132
141
|
begin
|
|
133
|
-
result << socket.readpartial([4096, maximum - result.bytesize].min)
|
|
142
|
+
result << socket.readpartial([4096, maximum + 1 - result.bytesize].min)
|
|
143
|
+
if result.bytesize > maximum
|
|
144
|
+
raise ToolCallError, "TCP response exceeds max_response_bytes or max_response_size"
|
|
145
|
+
end
|
|
134
146
|
rescue EOFError
|
|
135
147
|
break
|
|
136
148
|
end
|
data/lib/utcp/protocols/udp.rb
CHANGED
|
@@ -42,9 +42,11 @@ module UTCP
|
|
|
42
42
|
return nil if response_count.zero?
|
|
43
43
|
|
|
44
44
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + socket_timeout_seconds(template)
|
|
45
|
+
budget = ResponseByteBudget.new(template.max_response_bytes, "UDP")
|
|
45
46
|
values = response_count.times.map do
|
|
46
47
|
wait_readable!(socket, deadline, "UDP read")
|
|
47
48
|
payload = socket.recv(65_535)
|
|
49
|
+
budget.consume(payload)
|
|
48
50
|
decode_socket_payload(payload, template.response_byte_format)
|
|
49
51
|
end
|
|
50
52
|
values.length == 1 ? values.first : values
|