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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +22 -0
- data/Makefile +226 -0
- data/README.md +331 -0
- data/examples/basic.rb +33 -0
- data/examples/cli.rb +32 -0
- data/examples/generated/__init__.py +1 -0
- data/examples/generated/utcp_pb2.py +46 -0
- data/examples/generated/utcp_pb2_grpc.py +183 -0
- data/examples/graphql.rb +15 -0
- data/examples/grpc.rb +42 -0
- data/examples/grpc_python.py +52 -0
- data/examples/http.rb +17 -0
- data/examples/mcp.rb +28 -0
- data/examples/servers/graphql_server.rb +39 -0
- data/examples/servers/grpc_server.py +97 -0
- data/examples/servers/grpc_server.rb +62 -0
- data/examples/servers/http_helpers.rb +34 -0
- data/examples/servers/http_server.rb +28 -0
- data/examples/servers/mcp_stdio_server.rb +43 -0
- data/examples/servers/requirements-grpc.txt +2 -0
- data/examples/servers/sse_server.rb +36 -0
- data/examples/servers/streamable_http_server.rb +39 -0
- data/examples/servers/tcp_server.rb +58 -0
- data/examples/servers/udp_server.rb +33 -0
- data/examples/servers/webrtc_server.rb +78 -0
- data/examples/servers/websocket_server.rb +92 -0
- data/examples/sse.rb +16 -0
- data/examples/streamable_http.rb +17 -0
- data/examples/tcp.rb +20 -0
- data/examples/text.rb +23 -0
- data/examples/udp.rb +18 -0
- data/examples/webrtc.rb +19 -0
- data/examples/websocket.rb +17 -0
- data/lib/ruby-utcp.rb +4 -0
- data/lib/utcp/client.rb +217 -0
- data/lib/utcp/config.rb +79 -0
- data/lib/utcp/errors.rb +48 -0
- data/lib/utcp/migration.rb +88 -0
- data/lib/utcp/models.rb +794 -0
- data/lib/utcp/openapi_converter.rb +179 -0
- data/lib/utcp/protocols/base.rb +97 -0
- data/lib/utcp/protocols/cli.rb +186 -0
- data/lib/utcp/protocols/file.rb +52 -0
- data/lib/utcp/protocols/graphql.rb +277 -0
- data/lib/utcp/protocols/grpc.rb +207 -0
- data/lib/utcp/protocols/http.rb +340 -0
- data/lib/utcp/protocols/http_stream_support.rb +122 -0
- data/lib/utcp/protocols/mcp.rb +339 -0
- data/lib/utcp/protocols/socket_support.rb +51 -0
- data/lib/utcp/protocols/sse.rb +107 -0
- data/lib/utcp/protocols/streamable_http.rb +78 -0
- data/lib/utcp/protocols/tcp.rb +143 -0
- data/lib/utcp/protocols/text.rb +44 -0
- data/lib/utcp/protocols/udp.rb +61 -0
- data/lib/utcp/protocols/webrtc.rb +217 -0
- data/lib/utcp/protocols/websocket.rb +350 -0
- data/lib/utcp/registry.rb +67 -0
- data/lib/utcp/repository.rb +137 -0
- data/lib/utcp/serializer.rb +71 -0
- data/lib/utcp/utils.rb +118 -0
- data/lib/utcp/variables.rb +170 -0
- data/lib/utcp/version.rb +6 -0
- data/lib/utcp.rb +70 -0
- data/proto/utcp.proto +31 -0
- metadata +148 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module UTCP
|
|
6
|
+
class OpenAPIConverter
|
|
7
|
+
HTTP_METHODS = %w[get post put delete patch head options].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(spec, spec_url: nil, call_template_name: nil, auth_tools: nil, base_url: nil)
|
|
10
|
+
@spec = Utils.stringify_keys(Utils.hash!(spec, "OpenAPI document"))
|
|
11
|
+
@spec_url = spec_url
|
|
12
|
+
@call_template_name = call_template_name
|
|
13
|
+
@auth_tools = auth_tools
|
|
14
|
+
@base_url = base_url
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def convert
|
|
18
|
+
paths = @spec["paths"]
|
|
19
|
+
raise ValidationError, "OpenAPI document must contain a paths object" unless paths.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
tools = []
|
|
22
|
+
paths.each do |path, path_item|
|
|
23
|
+
next unless path_item.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
HTTP_METHODS.each do |method|
|
|
26
|
+
operation = path_item[method]
|
|
27
|
+
next unless operation.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
tools << convert_operation(path, method, path_item, operation)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Manual.new(
|
|
34
|
+
utcp_version: VERSION,
|
|
35
|
+
manual_version: "1.0.0",
|
|
36
|
+
info: @spec["info"] || {},
|
|
37
|
+
tools: tools
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def convert_operation(path, method, path_item, operation)
|
|
44
|
+
parameters = Array(path_item["parameters"]) + Array(operation["parameters"])
|
|
45
|
+
properties = {}
|
|
46
|
+
required = []
|
|
47
|
+
header_fields = []
|
|
48
|
+
body_field = "body"
|
|
49
|
+
|
|
50
|
+
parameters.each do |parameter|
|
|
51
|
+
next unless parameter.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
parameter = Utils.stringify_keys(parameter)
|
|
54
|
+
name = parameter["name"].to_s
|
|
55
|
+
next if name.empty?
|
|
56
|
+
|
|
57
|
+
if parameter["in"] == "body"
|
|
58
|
+
properties[body_field] = resolve_schema(parameter["schema"] || {})
|
|
59
|
+
required << body_field if parameter["required"]
|
|
60
|
+
else
|
|
61
|
+
properties[name] = resolve_schema(parameter["schema"] || parameter_schema(parameter))
|
|
62
|
+
required << name if parameter["required"] || parameter["in"] == "path"
|
|
63
|
+
header_fields << name if parameter["in"] == "header"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
request_body = operation["requestBody"]
|
|
68
|
+
if request_body.is_a?(Hash)
|
|
69
|
+
content_type, media = Array(request_body["content"]).first
|
|
70
|
+
if media.is_a?(Hash)
|
|
71
|
+
properties[body_field] = resolve_schema(media["schema"] || {})
|
|
72
|
+
required << body_field if request_body["required"]
|
|
73
|
+
end
|
|
74
|
+
else
|
|
75
|
+
content_type = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
tool_name = operation["operationId"].to_s
|
|
79
|
+
tool_name = generated_name(method, path) if tool_name.empty?
|
|
80
|
+
description = operation["description"] || operation["summary"] || "#{method.upcase} #{path}"
|
|
81
|
+
input_schema = { "type" => "object", "properties" => properties }
|
|
82
|
+
input_schema["required"] = required.uniq unless required.empty?
|
|
83
|
+
|
|
84
|
+
Tool.new(
|
|
85
|
+
name: tool_name,
|
|
86
|
+
description: description.to_s,
|
|
87
|
+
inputs: input_schema,
|
|
88
|
+
outputs: response_schema(operation),
|
|
89
|
+
tags: Array(operation["tags"]),
|
|
90
|
+
tool_call_template: HttpCallTemplate.new(
|
|
91
|
+
name: tool_name,
|
|
92
|
+
url: join_url(resolve_base_url, path),
|
|
93
|
+
http_method: method.upcase,
|
|
94
|
+
content_type: content_type || Array(operation["consumes"]).first || "application/json",
|
|
95
|
+
body_field: body_field,
|
|
96
|
+
header_fields: header_fields,
|
|
97
|
+
auth: operation_requires_auth?(operation) ? @auth_tools : nil
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parameter_schema(parameter)
|
|
103
|
+
parameter.each_with_object({}) do |(key, value), schema|
|
|
104
|
+
schema[key] = value if %w[type format enum default items minimum maximum pattern minLength maxLength].include?(key)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolve_schema(schema, seen = [])
|
|
109
|
+
data = Utils.stringify_keys(schema || {})
|
|
110
|
+
reference = data["$ref"]
|
|
111
|
+
return data unless reference&.start_with?("#/")
|
|
112
|
+
return {} if seen.include?(reference)
|
|
113
|
+
|
|
114
|
+
resolved = reference.sub(%r{\A#/}, "").split("/").reduce(@spec) do |node, component|
|
|
115
|
+
break nil unless node.is_a?(Hash)
|
|
116
|
+
|
|
117
|
+
node[component.gsub("~1", "/").gsub("~0", "~")]
|
|
118
|
+
end
|
|
119
|
+
resolved ? resolve_schema(resolved, seen + [reference]) : data
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def response_schema(operation)
|
|
123
|
+
responses = operation["responses"]
|
|
124
|
+
return {} unless responses.is_a?(Hash)
|
|
125
|
+
|
|
126
|
+
_status, response = responses.find { |status, _value| status.to_s.match?(/\A2\d\d\z/) } || responses.first
|
|
127
|
+
return {} unless response.is_a?(Hash)
|
|
128
|
+
|
|
129
|
+
if response["content"].is_a?(Hash)
|
|
130
|
+
media = response["content"].values.first
|
|
131
|
+
resolve_schema(media.is_a?(Hash) ? media["schema"] : {})
|
|
132
|
+
else
|
|
133
|
+
resolve_schema(response["schema"] || {})
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def operation_requires_auth?(operation)
|
|
138
|
+
security = operation.key?("security") ? operation["security"] : @spec["security"]
|
|
139
|
+
security.is_a?(Array) && !security.empty?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def resolve_base_url
|
|
143
|
+
return @base_url if @base_url && !@base_url.empty?
|
|
144
|
+
|
|
145
|
+
server = Array(@spec["servers"]).first
|
|
146
|
+
return substitute_server_variables(server) if server.is_a?(Hash) && server["url"]
|
|
147
|
+
|
|
148
|
+
if @spec["host"]
|
|
149
|
+
scheme = Array(@spec["schemes"]).first || "https"
|
|
150
|
+
return "#{scheme}://#{@spec['host']}#{@spec['basePath']}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if @spec_url&.match?(/\Ahttps?:/)
|
|
154
|
+
uri = URI.parse(@spec_url)
|
|
155
|
+
return "#{uri.scheme}://#{uri.host}#{uri.port && ![80, 443].include?(uri.port) ? ":#{uri.port}" : ""}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
raise ValidationError, "OpenAPI document does not define a server URL; pass base_url"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def substitute_server_variables(server)
|
|
162
|
+
variables = Utils.stringify_keys(server["variables"] || {})
|
|
163
|
+
server["url"].gsub(/\{([^}]+)\}/) do
|
|
164
|
+
variable = variables[Regexp.last_match(1)]
|
|
165
|
+
variable.is_a?(Hash) ? variable["default"].to_s : Regexp.last_match(0)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def join_url(base, path)
|
|
170
|
+
"#{base.to_s.sub(%r{/+\z}, '')}/#{path.to_s.sub(%r{\A/+}, '')}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def generated_name(method, path)
|
|
174
|
+
"#{method}_#{path}".gsub(/[^A-Za-z0-9_]+/, "_").gsub(/\A_+|_+\z/, "")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
OpenApiConverter = OpenAPIConverter
|
|
178
|
+
end
|
|
179
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module UTCP
|
|
6
|
+
class CommunicationProtocol
|
|
7
|
+
def register_manual(_client, _manual_call_template)
|
|
8
|
+
raise NotImplementedError
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def deregister_manual(_client, _manual_call_template)
|
|
12
|
+
nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call_tool(_client, _tool_name, _tool_args, _tool_call_template)
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call_tool_streaming(client, tool_name, tool_args, tool_call_template)
|
|
20
|
+
return enum_for(__method__, client, tool_name, tool_args, tool_call_template) unless block_given?
|
|
21
|
+
|
|
22
|
+
yield call_tool(client, tool_name, tool_args, tool_call_template)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def manual_from_payload(template, payload, source: "protocol response")
|
|
28
|
+
data = if payload.is_a?(String)
|
|
29
|
+
Utils.parse_document(payload, source: source)
|
|
30
|
+
else
|
|
31
|
+
Utils.stringify_keys(payload)
|
|
32
|
+
end
|
|
33
|
+
data = { "tools" => data } if data.is_a?(Array)
|
|
34
|
+
data = Migration.manual_v0_1_to_v1_1(data) if Migration.v0_1_manual?(data)
|
|
35
|
+
data = Utils.hash!(data, source)
|
|
36
|
+
data["utcp_version"] ||= VERSION
|
|
37
|
+
data["manual_version"] ||= "1.0.0"
|
|
38
|
+
data["tools"] = Utils.array!(data.fetch("tools", []), "#{source}.tools").map do |raw_tool|
|
|
39
|
+
tool = Utils.stringify_keys(Utils.hash!(raw_tool, "#{source}.tool"))
|
|
40
|
+
tool["tool_call_template"] ||= tool.delete("tool_provider") || template.to_h
|
|
41
|
+
tool
|
|
42
|
+
end
|
|
43
|
+
Manual.from_h(data)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def decode_json_or_text(value)
|
|
47
|
+
return value unless value.is_a?(String)
|
|
48
|
+
|
|
49
|
+
stripped = value.strip
|
|
50
|
+
return value if stripped.empty? || !stripped.start_with?("{", "[", '"') && stripped !~ /\A(?:true|false|null|-?\d)/
|
|
51
|
+
|
|
52
|
+
JSON.parse(stripped)
|
|
53
|
+
rescue JSON::ParserError
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def substitute_message_template(value, arguments)
|
|
58
|
+
args = Utils.stringify_keys(arguments || {})
|
|
59
|
+
case value
|
|
60
|
+
when Hash
|
|
61
|
+
value.each_with_object({}) do |(key, item), result|
|
|
62
|
+
result[key.to_s] = substitute_message_template(item, args)
|
|
63
|
+
end
|
|
64
|
+
when Array
|
|
65
|
+
value.map { |item| substitute_message_template(item, args) }
|
|
66
|
+
when String
|
|
67
|
+
value.gsub(/UTCP_ARG_([A-Za-z0-9_]+)_UTCP_ARG/) do
|
|
68
|
+
name = Regexp.last_match(1)
|
|
69
|
+
raise ToolCallError, "Missing required transport argument: #{name}" unless args.key?(name)
|
|
70
|
+
|
|
71
|
+
replacement = args[name]
|
|
72
|
+
replacement.is_a?(String) ? replacement : JSON.generate(replacement)
|
|
73
|
+
end
|
|
74
|
+
else
|
|
75
|
+
value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def success(template, manual)
|
|
80
|
+
RegisterManualResult.new(
|
|
81
|
+
manual_call_template: template,
|
|
82
|
+
manual: manual,
|
|
83
|
+
success: true,
|
|
84
|
+
errors: []
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def failure(template, error)
|
|
89
|
+
RegisterManualResult.new(
|
|
90
|
+
manual_call_template: template,
|
|
91
|
+
manual: Manual.new(tools: [], manual_version: "0.0.0"),
|
|
92
|
+
success: false,
|
|
93
|
+
errors: [error.is_a?(Exception) ? error.message : error.to_s]
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module UTCP
|
|
8
|
+
class CLIProtocol < CommunicationProtocol
|
|
9
|
+
PLACEHOLDER = /UTCP_ARG_([A-Za-z0-9_]+)_UTCP_END/.freeze
|
|
10
|
+
DEFAULT_ENVIRONMENT = %w[
|
|
11
|
+
PATH HOME LANG LANGUAGE LC_ALL LC_CTYPE TMPDIR TEMP TMP SHELL USER LOGNAME
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def register_manual(client, template)
|
|
15
|
+
assert_template!(template)
|
|
16
|
+
output = execute(client, template, {})
|
|
17
|
+
data = Utils.parse_document(output, source: "CLI output")
|
|
18
|
+
data = { "utcp_version" => VERSION, "manual_version" => "1.0.0", "tools" => data } if data.is_a?(Array)
|
|
19
|
+
data = Migration.manual_v0_1_to_v1_1(data) if Migration.v0_1_manual?(data)
|
|
20
|
+
success(template, Manual.from_h(data))
|
|
21
|
+
rescue StandardError => error
|
|
22
|
+
client.logger.warn("Unable to register CLI manual #{template.name.inspect}: #{error.message}")
|
|
23
|
+
failure(template, error)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call_tool(client, tool_name, tool_args, template)
|
|
27
|
+
assert_template!(template)
|
|
28
|
+
output = execute(client, template, tool_args || {})
|
|
29
|
+
stripped = output.strip
|
|
30
|
+
if stripped.start_with?("{", "[")
|
|
31
|
+
JSON.parse(stripped)
|
|
32
|
+
else
|
|
33
|
+
stripped
|
|
34
|
+
end
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
output.strip
|
|
37
|
+
rescue Error
|
|
38
|
+
raise
|
|
39
|
+
rescue StandardError => error
|
|
40
|
+
raise ToolCallError.new("CLI tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def assert_template!(template)
|
|
46
|
+
return if template.is_a?(CliCallTemplate)
|
|
47
|
+
|
|
48
|
+
raise ValidationError, "CLI protocol requires a CliCallTemplate"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def execute(client, template, arguments)
|
|
52
|
+
env = environment_for(template)
|
|
53
|
+
commands = template.commands.each_with_index.map do |step, index|
|
|
54
|
+
command, argument_env = interpolate(step.command, arguments)
|
|
55
|
+
env.merge!(argument_env)
|
|
56
|
+
[command, include_output?(step, index, template.commands.length)]
|
|
57
|
+
end
|
|
58
|
+
script = build_script(commands)
|
|
59
|
+
working_dir = template.working_dir ? File.expand_path(template.working_dir, client.root_dir) : client.root_dir
|
|
60
|
+
raise ToolCallError, "CLI working directory does not exist: #{working_dir}" unless File.directory?(working_dir)
|
|
61
|
+
|
|
62
|
+
run_script(script, env, working_dir, template.timeout)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def environment_for(template)
|
|
66
|
+
names = template.inherit_env_vars.nil? ? DEFAULT_ENVIRONMENT : template.inherit_env_vars
|
|
67
|
+
environment = names.each_with_object({}) do |name, selected|
|
|
68
|
+
selected[name] = ENV[name] if ENV.key?(name)
|
|
69
|
+
end
|
|
70
|
+
template.env_vars.each { |name, value| environment[name] = value.to_s }
|
|
71
|
+
environment
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def interpolate(command, arguments)
|
|
75
|
+
args = Utils.stringify_keys(arguments)
|
|
76
|
+
env = {}
|
|
77
|
+
result = +""
|
|
78
|
+
quote = nil
|
|
79
|
+
escaped = false
|
|
80
|
+
index = 0
|
|
81
|
+
variable_numbers = {}
|
|
82
|
+
|
|
83
|
+
while index < command.length
|
|
84
|
+
match = PLACEHOLDER.match(command, index)
|
|
85
|
+
if match && match.begin(0) == index
|
|
86
|
+
name = match[1]
|
|
87
|
+
raise ToolCallError, "Missing required CLI argument: #{name}" unless args.key?(name)
|
|
88
|
+
|
|
89
|
+
number = variable_numbers.fetch(name) { variable_numbers[name] = variable_numbers.length }
|
|
90
|
+
variable = "UTCP_TOOL_ARG_#{number}"
|
|
91
|
+
value = args[name]
|
|
92
|
+
env[variable] = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
|
|
93
|
+
result << case quote
|
|
94
|
+
when "'" then %Q('"${#{variable}}"')
|
|
95
|
+
when '"' then "${#{variable}}"
|
|
96
|
+
else %Q("${#{variable}}")
|
|
97
|
+
end
|
|
98
|
+
index = match.end(0)
|
|
99
|
+
next
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
character = command[index]
|
|
103
|
+
result << character
|
|
104
|
+
if escaped
|
|
105
|
+
escaped = false
|
|
106
|
+
elsif character == "\\" && quote != "'"
|
|
107
|
+
escaped = true
|
|
108
|
+
elsif character == "'" && quote != '"'
|
|
109
|
+
quote = quote == "'" ? nil : "'"
|
|
110
|
+
elsif character == '"' && quote != "'"
|
|
111
|
+
quote = quote == '"' ? nil : '"'
|
|
112
|
+
end
|
|
113
|
+
index += 1
|
|
114
|
+
end
|
|
115
|
+
[result, env]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def include_output?(step, index, length)
|
|
119
|
+
step.append_to_final_output.nil? ? index == length - 1 : step.append_to_final_output
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build_script(commands)
|
|
123
|
+
lines = []
|
|
124
|
+
commands.each_with_index do |(command, append), index|
|
|
125
|
+
lines << "__utcp_output_#{index}=$( { #{command}; } 2>&1 )"
|
|
126
|
+
lines << "__utcp_status_#{index}=$?"
|
|
127
|
+
lines << "CMD_#{index}_OUTPUT=$__utcp_output_#{index}"
|
|
128
|
+
lines << "export CMD_#{index}_OUTPUT"
|
|
129
|
+
lines << "printf '%s\\n' \"$__utcp_output_#{index}\"" if append
|
|
130
|
+
lines << "if [ \"$__utcp_status_#{index}\" -ne 0 ]; then printf '%s\\n' \"$__utcp_output_#{index}\" >&2; exit \"$__utcp_status_#{index}\"; fi"
|
|
131
|
+
end
|
|
132
|
+
lines.join("\n")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def run_script(script, environment, working_dir, timeout_seconds)
|
|
136
|
+
stdout_text = nil
|
|
137
|
+
stderr_text = nil
|
|
138
|
+
status = nil
|
|
139
|
+
wait_thread = nil
|
|
140
|
+
|
|
141
|
+
Open3.popen3(
|
|
142
|
+
environment,
|
|
143
|
+
"/bin/sh", "-c", script,
|
|
144
|
+
unsetenv_others: true,
|
|
145
|
+
chdir: working_dir,
|
|
146
|
+
pgroup: true
|
|
147
|
+
) do |stdin, stdout, stderr, thread|
|
|
148
|
+
wait_thread = thread
|
|
149
|
+
stdin.close
|
|
150
|
+
stdout_reader = Thread.new { stdout.read }
|
|
151
|
+
stderr_reader = Thread.new { stderr.read }
|
|
152
|
+
begin
|
|
153
|
+
Timeout.timeout(timeout_seconds) do
|
|
154
|
+
stdout_text = stdout_reader.value
|
|
155
|
+
stderr_text = stderr_reader.value
|
|
156
|
+
status = thread.value
|
|
157
|
+
end
|
|
158
|
+
rescue Timeout::Error
|
|
159
|
+
terminate_process_group(thread.pid)
|
|
160
|
+
stdout_reader.kill
|
|
161
|
+
stderr_reader.kill
|
|
162
|
+
raise TimeoutError, "CLI command timed out after #{timeout_seconds} seconds"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
unless status&.success?
|
|
167
|
+
detail = stderr_text.to_s.strip
|
|
168
|
+
detail = stdout_text.to_s.strip if detail.empty?
|
|
169
|
+
raise ToolCallError.new("CLI command exited with status #{status&.exitstatus}: #{detail}", status: status&.exitstatus)
|
|
170
|
+
end
|
|
171
|
+
stdout_text.to_s
|
|
172
|
+
ensure
|
|
173
|
+
terminate_process_group(wait_thread.pid) if wait_thread&.alive?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def terminate_process_group(pid)
|
|
177
|
+
Process.kill("TERM", -pid)
|
|
178
|
+
sleep(0.05)
|
|
179
|
+
Process.kill("KILL", -pid)
|
|
180
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
CliCommunicationProtocol = CLIProtocol
|
|
185
|
+
end
|
|
186
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module UTCP
|
|
6
|
+
class FileProtocol < CommunicationProtocol
|
|
7
|
+
def register_manual(client, template)
|
|
8
|
+
assert_template!(template)
|
|
9
|
+
path = resolve_path(client, template.file_path)
|
|
10
|
+
data = Utils.load_document(path)
|
|
11
|
+
manual = if openapi?(data)
|
|
12
|
+
OpenAPIConverter.new(
|
|
13
|
+
data,
|
|
14
|
+
spec_url: Pathname.new(path).expand_path.to_s,
|
|
15
|
+
call_template_name: template.name,
|
|
16
|
+
auth_tools: template.auth_tools
|
|
17
|
+
).convert
|
|
18
|
+
else
|
|
19
|
+
Manual.from_h(data)
|
|
20
|
+
end
|
|
21
|
+
success(template, manual)
|
|
22
|
+
rescue StandardError => error
|
|
23
|
+
client.logger.warn("Unable to register file manual #{template.name.inspect}: #{error.message}")
|
|
24
|
+
failure(template, error)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call_tool(client, _tool_name, _tool_args, template)
|
|
28
|
+
assert_template!(template)
|
|
29
|
+
File.read(resolve_path(client, template.file_path), mode: "r:bom|utf-8")
|
|
30
|
+
rescue Errno::ENOENT, Errno::EACCES => error
|
|
31
|
+
raise ToolCallError, "Unable to read tool file: #{error.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def assert_template!(template)
|
|
37
|
+
return if template.is_a?(FileCallTemplate)
|
|
38
|
+
|
|
39
|
+
raise ValidationError, "file protocol requires a FileCallTemplate"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolve_path(client, path)
|
|
43
|
+
File.expand_path(path, client.root_dir)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def openapi?(data)
|
|
47
|
+
data.is_a?(Hash) && (data.key?("openapi") || data.key?("swagger") || data.key?("paths"))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
FileCommunicationProtocol = FileProtocol
|
|
51
|
+
end
|
|
52
|
+
|