utcp-socket 1.0.2__py3-none-any.whl
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.
- utcp_socket/__init__.py +18 -0
- utcp_socket/tcp_call_template.py +99 -0
- utcp_socket/tcp_communication_protocol.py +434 -0
- utcp_socket/udp_call_template.py +56 -0
- utcp_socket/udp_communication_protocol.py +337 -0
- utcp_socket-1.0.2.dist-info/METADATA +69 -0
- utcp_socket-1.0.2.dist-info/RECORD +10 -0
- utcp_socket-1.0.2.dist-info/WHEEL +5 -0
- utcp_socket-1.0.2.dist-info/entry_points.txt +2 -0
- utcp_socket-1.0.2.dist-info/top_level.txt +1 -0
utcp_socket/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from utcp.plugins.discovery import register_communication_protocol, register_call_template
|
|
2
|
+
from utcp_socket.tcp_communication_protocol import TCPTransport
|
|
3
|
+
from utcp_socket.udp_communication_protocol import UDPTransport
|
|
4
|
+
from utcp_socket.tcp_call_template import TCPProviderSerializer
|
|
5
|
+
from utcp_socket.udp_call_template import UDPProviderSerializer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def register() -> None:
|
|
9
|
+
# Register communication protocols
|
|
10
|
+
register_communication_protocol("tcp", TCPTransport())
|
|
11
|
+
register_communication_protocol("udp", UDPTransport())
|
|
12
|
+
|
|
13
|
+
# Register call templates and their serializers
|
|
14
|
+
register_call_template("tcp", TCPProviderSerializer())
|
|
15
|
+
register_call_template("udp", UDPProviderSerializer())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["register"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from utcp.data.call_template import CallTemplate
|
|
2
|
+
from typing import Optional, Literal
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from utcp.interfaces.serializer import Serializer
|
|
5
|
+
from utcp.exceptions import UtcpSerializerValidationError
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
class TCPProvider(CallTemplate):
|
|
9
|
+
"""Provider configuration for raw TCP socket tools.
|
|
10
|
+
|
|
11
|
+
Enables direct communication with TCP servers using custom protocols.
|
|
12
|
+
Supports flexible request formatting, response decoding, and multiple
|
|
13
|
+
framing strategies for message boundaries.
|
|
14
|
+
|
|
15
|
+
Request Data Handling:
|
|
16
|
+
- 'json' format: Arguments formatted as JSON object
|
|
17
|
+
- 'text' format: Template-based with UTCP_ARG_argname_UTCP_ARG placeholders
|
|
18
|
+
|
|
19
|
+
Response Data Handling:
|
|
20
|
+
- If response_byte_format is None: Returns raw bytes
|
|
21
|
+
- If response_byte_format is encoding string: Decodes bytes to text
|
|
22
|
+
|
|
23
|
+
TCP Stream Framing Options:
|
|
24
|
+
1. Length-prefix: Set framing_strategy='length_prefix' + length_prefix_bytes
|
|
25
|
+
2. Delimiter-based: Set framing_strategy='delimiter' + message_delimiter
|
|
26
|
+
3. Fixed-length: Set framing_strategy='fixed_length' + fixed_message_length
|
|
27
|
+
4. Stream-based: Set framing_strategy='stream' (reads until connection closes)
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
call_template_type: Always "tcp" for TCP providers.
|
|
31
|
+
host: The hostname or IP address of the TCP server.
|
|
32
|
+
port: The port number of the TCP server.
|
|
33
|
+
request_data_format: Format for request data ('json' or 'text').
|
|
34
|
+
request_data_template: Template string for 'text' format with placeholders.
|
|
35
|
+
response_byte_format: Encoding for response decoding (None for raw bytes).
|
|
36
|
+
framing_strategy: Method for detecting message boundaries.
|
|
37
|
+
length_prefix_bytes: Number of bytes for length prefix (1, 2, 4, or 8).
|
|
38
|
+
length_prefix_endian: Byte order for length prefix ('big' or 'little').
|
|
39
|
+
message_delimiter: Delimiter string for message boundaries.
|
|
40
|
+
fixed_message_length: Fixed length in bytes for each message.
|
|
41
|
+
max_response_size: Maximum bytes to read for stream-based framing.
|
|
42
|
+
timeout: Connection timeout in milliseconds.
|
|
43
|
+
auth: Always None - TCP providers don't support authentication.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
call_template_type: Literal["tcp"] = "tcp"
|
|
47
|
+
host: str
|
|
48
|
+
port: int
|
|
49
|
+
request_data_format: Literal["json", "text"] = "json"
|
|
50
|
+
request_data_template: Optional[str] = None
|
|
51
|
+
response_byte_format: Optional[str] = Field(default="utf-8", description="Encoding to decode response bytes. If None, returns raw bytes.")
|
|
52
|
+
# TCP Framing Strategy
|
|
53
|
+
framing_strategy: Literal["length_prefix", "delimiter", "fixed_length", "stream"] = Field(
|
|
54
|
+
default="stream",
|
|
55
|
+
description="Strategy for framing TCP messages"
|
|
56
|
+
)
|
|
57
|
+
# Length-prefix framing options
|
|
58
|
+
length_prefix_bytes: Literal[1, 2, 4, 8] = Field(
|
|
59
|
+
default=4,
|
|
60
|
+
description="Number of bytes for length prefix (1, 2, 4, or 8). Used with 'length_prefix' framing."
|
|
61
|
+
)
|
|
62
|
+
length_prefix_endian: Literal["big", "little"] = Field(
|
|
63
|
+
default="big",
|
|
64
|
+
description="Byte order for length prefix. Used with 'length_prefix' framing."
|
|
65
|
+
)
|
|
66
|
+
# Delimiter-based framing options
|
|
67
|
+
message_delimiter: str = Field(
|
|
68
|
+
default='\x00',
|
|
69
|
+
description="Delimiter to detect end of TCP response (e.g., '\n', '\r\n', '\x00'). Used with 'delimiter' framing."
|
|
70
|
+
)
|
|
71
|
+
interpret_escape_sequences: bool = Field(
|
|
72
|
+
default=True,
|
|
73
|
+
description="If True, interpret Python-style escape sequences in message_delimiter (e.g., '\\n', '\\r\\n', '\\x00'). If False, use the delimiter literally as provided."
|
|
74
|
+
)
|
|
75
|
+
# Fixed-length framing options
|
|
76
|
+
fixed_message_length: Optional[int] = Field(
|
|
77
|
+
default=None,
|
|
78
|
+
description="Fixed length of each message in bytes. Used with 'fixed_length' framing."
|
|
79
|
+
)
|
|
80
|
+
# Stream-based options
|
|
81
|
+
max_response_size: int = Field(
|
|
82
|
+
default=65536,
|
|
83
|
+
description="Maximum bytes to read from TCP stream. Used with 'stream' framing."
|
|
84
|
+
)
|
|
85
|
+
timeout: int = 30000
|
|
86
|
+
auth: None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TCPProviderSerializer(Serializer[TCPProvider]):
|
|
90
|
+
def to_dict(self, obj: TCPProvider) -> dict:
|
|
91
|
+
return obj.model_dump()
|
|
92
|
+
|
|
93
|
+
def validate_dict(self, data: dict) -> TCPProvider:
|
|
94
|
+
try:
|
|
95
|
+
return TCPProvider.model_validate(data)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
raise UtcpSerializerValidationError(
|
|
98
|
+
f"Invalid TCPProvider: {e}\n{traceback.format_exc()}"
|
|
99
|
+
)
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Transmission Control Protocol (TCP) transport for UTCP client.
|
|
3
|
+
|
|
4
|
+
This transport communicates with tools over TCP sockets.
|
|
5
|
+
"""
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import socket
|
|
9
|
+
import struct
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Dict, Any, List, Optional, Callable, Union
|
|
12
|
+
|
|
13
|
+
from utcp.interfaces.communication_protocol import CommunicationProtocol
|
|
14
|
+
from utcp_socket.tcp_call_template import TCPProvider, TCPProviderSerializer
|
|
15
|
+
from utcp.data.tool import Tool
|
|
16
|
+
from utcp.data.call_template import CallTemplate, CallTemplateSerializer
|
|
17
|
+
from utcp.data.register_manual_response import RegisterManualResult
|
|
18
|
+
from utcp.data.utcp_manual import UtcpManual
|
|
19
|
+
import logging
|
|
20
|
+
|
|
21
|
+
logging.basicConfig(
|
|
22
|
+
level=logging.INFO,
|
|
23
|
+
format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
class TCPTransport(CommunicationProtocol):
|
|
29
|
+
"""Transport implementation for TCP-based tool providers.
|
|
30
|
+
|
|
31
|
+
This transport communicates with tools over TCP sockets. It supports:
|
|
32
|
+
- Tool discovery via TCP messages
|
|
33
|
+
- Tool execution by sending TCP packets with arguments
|
|
34
|
+
- Multiple framing strategies: length-prefix, delimiter, fixed-length, and stream
|
|
35
|
+
- JSON and text-based request formatting
|
|
36
|
+
- Template-based argument substitution
|
|
37
|
+
- Configurable response byte format (text encoding or raw bytes)
|
|
38
|
+
- Connection management for each request
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, logger: Optional[Callable[[str], None]] = None):
|
|
42
|
+
"""Initialize the TCP transport.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
logger: Optional logger function for debugging
|
|
46
|
+
"""
|
|
47
|
+
self._log = logger or (lambda *args, **kwargs: None)
|
|
48
|
+
|
|
49
|
+
def _log_info(self, message: str):
|
|
50
|
+
"""Log informational messages."""
|
|
51
|
+
self._log(f"[TCPTransport] {message}")
|
|
52
|
+
|
|
53
|
+
def _log_error(self, message: str):
|
|
54
|
+
"""Log error messages."""
|
|
55
|
+
logger.error(f"[TCPTransport Error] {message}")
|
|
56
|
+
|
|
57
|
+
def _format_tool_call_message(
|
|
58
|
+
self,
|
|
59
|
+
tool_args: Dict[str, Any],
|
|
60
|
+
provider: TCPProvider
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Format a tool call message based on provider configuration.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
tool_args: Arguments for the tool call
|
|
66
|
+
provider: The TCPProvider with formatting configuration
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Formatted message string
|
|
70
|
+
"""
|
|
71
|
+
if provider.request_data_format == "json":
|
|
72
|
+
return json.dumps(tool_args)
|
|
73
|
+
elif provider.request_data_format == "text":
|
|
74
|
+
# Use template-based formatting
|
|
75
|
+
if provider.request_data_template is not None and provider.request_data_template != "":
|
|
76
|
+
message = provider.request_data_template
|
|
77
|
+
# Replace placeholders with argument values
|
|
78
|
+
for arg_name, arg_value in tool_args.items():
|
|
79
|
+
placeholder = f"UTCP_ARG_{arg_name}_UTCP_ARG"
|
|
80
|
+
if isinstance(arg_value, str):
|
|
81
|
+
message = message.replace(placeholder, arg_value)
|
|
82
|
+
else:
|
|
83
|
+
message = message.replace(placeholder, json.dumps(arg_value))
|
|
84
|
+
return message
|
|
85
|
+
else:
|
|
86
|
+
# Fallback to simple key=value format
|
|
87
|
+
return " ".join([str(v) for k, v in tool_args.items()])
|
|
88
|
+
else:
|
|
89
|
+
# Default to JSON format
|
|
90
|
+
return json.dumps(tool_args)
|
|
91
|
+
|
|
92
|
+
def _ensure_tool_call_template(self, tool_data: Dict[str, Any], manual_call_template: TCPProvider) -> Dict[str, Any]:
|
|
93
|
+
"""Normalize tool definition to include a valid 'tool_call_template'.
|
|
94
|
+
|
|
95
|
+
- If 'tool_call_template' exists, validate it.
|
|
96
|
+
- Else if legacy 'tool_provider' exists, convert using TCPProviderSerializer.
|
|
97
|
+
- Else default to the provided manual_call_template.
|
|
98
|
+
"""
|
|
99
|
+
normalized = dict(tool_data)
|
|
100
|
+
try:
|
|
101
|
+
if "tool_call_template" in normalized and normalized["tool_call_template"] is not None:
|
|
102
|
+
try:
|
|
103
|
+
ctpl = CallTemplateSerializer().validate_dict(normalized["tool_call_template"]) # type: ignore
|
|
104
|
+
normalized["tool_call_template"] = ctpl
|
|
105
|
+
except Exception:
|
|
106
|
+
normalized["tool_call_template"] = manual_call_template
|
|
107
|
+
elif "tool_provider" in normalized and normalized["tool_provider"] is not None:
|
|
108
|
+
try:
|
|
109
|
+
ctpl = TCPProviderSerializer().validate_dict(normalized["tool_provider"]) # type: ignore
|
|
110
|
+
normalized.pop("tool_provider", None)
|
|
111
|
+
normalized["tool_call_template"] = ctpl
|
|
112
|
+
except Exception:
|
|
113
|
+
normalized.pop("tool_provider", None)
|
|
114
|
+
normalized["tool_call_template"] = manual_call_template
|
|
115
|
+
else:
|
|
116
|
+
normalized["tool_call_template"] = manual_call_template
|
|
117
|
+
except Exception:
|
|
118
|
+
normalized["tool_call_template"] = manual_call_template
|
|
119
|
+
return normalized
|
|
120
|
+
|
|
121
|
+
def _encode_message_with_framing(self, message: str, provider: TCPProvider) -> bytes:
|
|
122
|
+
"""Encode message with appropriate TCP framing.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
message: Message to encode
|
|
126
|
+
provider: TCPProvider with framing configuration
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Framed message bytes
|
|
130
|
+
"""
|
|
131
|
+
message_bytes = message.encode('utf-8')
|
|
132
|
+
|
|
133
|
+
if provider.framing_strategy == "length_prefix":
|
|
134
|
+
# Add length prefix before the message
|
|
135
|
+
length = len(message_bytes)
|
|
136
|
+
if provider.length_prefix_bytes == 1:
|
|
137
|
+
length_bytes = struct.pack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}B", length)
|
|
138
|
+
elif provider.length_prefix_bytes == 2:
|
|
139
|
+
length_bytes = struct.pack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}H", length)
|
|
140
|
+
elif provider.length_prefix_bytes == 4:
|
|
141
|
+
length_bytes = struct.pack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}I", length)
|
|
142
|
+
elif provider.length_prefix_bytes == 8:
|
|
143
|
+
length_bytes = struct.pack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}Q", length)
|
|
144
|
+
else:
|
|
145
|
+
raise ValueError(f"Invalid length_prefix_bytes: {provider.length_prefix_bytes}")
|
|
146
|
+
return length_bytes + message_bytes
|
|
147
|
+
|
|
148
|
+
elif provider.framing_strategy == "delimiter":
|
|
149
|
+
# Add delimiter after the message
|
|
150
|
+
delimiter = provider.message_delimiter or "\x00"
|
|
151
|
+
if provider.interpret_escape_sequences:
|
|
152
|
+
# Handle escape sequences (e.g., "\n", "\r\n", "\x00")
|
|
153
|
+
delimiter = delimiter.encode('utf-8').decode('unicode_escape')
|
|
154
|
+
delimiter_bytes = delimiter.encode('utf-8')
|
|
155
|
+
else:
|
|
156
|
+
# Use delimiter literally as provided
|
|
157
|
+
delimiter_bytes = delimiter.encode('utf-8')
|
|
158
|
+
return message_bytes + delimiter_bytes
|
|
159
|
+
|
|
160
|
+
elif provider.framing_strategy in ("fixed_length", "stream"):
|
|
161
|
+
# No additional framing needed
|
|
162
|
+
return message_bytes
|
|
163
|
+
|
|
164
|
+
else:
|
|
165
|
+
raise ValueError(f"Unknown framing strategy: {provider.framing_strategy}")
|
|
166
|
+
|
|
167
|
+
def _decode_response_with_framing(self, sock: socket.socket, provider: TCPProvider, timeout: float) -> bytes:
|
|
168
|
+
"""Decode response based on TCP framing strategy.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
sock: Connected TCP socket
|
|
172
|
+
provider: TCPProvider with framing configuration
|
|
173
|
+
timeout: Read timeout in seconds
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Response message bytes
|
|
177
|
+
"""
|
|
178
|
+
sock.settimeout(timeout)
|
|
179
|
+
|
|
180
|
+
if provider.framing_strategy == "length_prefix":
|
|
181
|
+
# Read length prefix first
|
|
182
|
+
length_bytes = sock.recv(provider.length_prefix_bytes)
|
|
183
|
+
if len(length_bytes) < provider.length_prefix_bytes:
|
|
184
|
+
raise Exception(f"Incomplete length prefix: got {len(length_bytes)} bytes, expected {provider.length_prefix_bytes}")
|
|
185
|
+
|
|
186
|
+
# Unpack length
|
|
187
|
+
if provider.length_prefix_bytes == 1:
|
|
188
|
+
length = struct.unpack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}B", length_bytes)[0]
|
|
189
|
+
elif provider.length_prefix_bytes == 2:
|
|
190
|
+
length = struct.unpack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}H", length_bytes)[0]
|
|
191
|
+
elif provider.length_prefix_bytes == 4:
|
|
192
|
+
length = struct.unpack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}I", length_bytes)[0]
|
|
193
|
+
elif provider.length_prefix_bytes == 8:
|
|
194
|
+
length = struct.unpack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}Q", length_bytes)[0]
|
|
195
|
+
else:
|
|
196
|
+
raise ValueError(f"Invalid length_prefix_bytes: {provider.length_prefix_bytes}")
|
|
197
|
+
|
|
198
|
+
# Read the message data
|
|
199
|
+
response_data = b""
|
|
200
|
+
while len(response_data) < length:
|
|
201
|
+
chunk = sock.recv(length - len(response_data))
|
|
202
|
+
if not chunk:
|
|
203
|
+
raise Exception("Connection closed while reading message")
|
|
204
|
+
response_data += chunk
|
|
205
|
+
|
|
206
|
+
return response_data
|
|
207
|
+
|
|
208
|
+
elif provider.framing_strategy == "delimiter":
|
|
209
|
+
# Read until delimiter is found
|
|
210
|
+
# Delimiter handling:
|
|
211
|
+
# The code supports both literal delimiters (e.g., "\\x00") and escape-sequence interpreted delimiters (e.g., "\x00")
|
|
212
|
+
# via the `interpret_escape_sequences` flag in TCPProvider. This ensures compatibility with both legacy and updated
|
|
213
|
+
# wire protocols. The delimiter is interpreted according to the flag, so no breaking change occurs unless the flag
|
|
214
|
+
# is set differently than expected by the server/client.
|
|
215
|
+
# Example:
|
|
216
|
+
# If interpret_escape_sequences is True, "\\x00" becomes a null byte; if False, it remains four literal bytes.
|
|
217
|
+
# delimiter = delimiter.encode('utf-8')
|
|
218
|
+
delimiter = provider.message_delimiter or "\x00"
|
|
219
|
+
if provider.interpret_escape_sequences:
|
|
220
|
+
delimiter_bytes = delimiter.encode('utf-8').decode('unicode_escape').encode('utf-8')
|
|
221
|
+
else:
|
|
222
|
+
delimiter_bytes = delimiter.encode('utf-8')
|
|
223
|
+
|
|
224
|
+
response_data = b""
|
|
225
|
+
while True:
|
|
226
|
+
chunk = sock.recv(1)
|
|
227
|
+
if not chunk:
|
|
228
|
+
raise Exception("Connection closed while reading message")
|
|
229
|
+
response_data += chunk
|
|
230
|
+
|
|
231
|
+
# Check if we've received the delimiter
|
|
232
|
+
if response_data.endswith(delimiter_bytes):
|
|
233
|
+
# Remove delimiter from response
|
|
234
|
+
return response_data[:-len(delimiter_bytes)]
|
|
235
|
+
|
|
236
|
+
elif provider.framing_strategy == "fixed_length":
|
|
237
|
+
# Read exactly fixed_message_length bytes
|
|
238
|
+
if provider.fixed_message_length is None:
|
|
239
|
+
raise ValueError("fixed_message_length must be set for fixed_length framing")
|
|
240
|
+
|
|
241
|
+
response_data = b""
|
|
242
|
+
while len(response_data) < provider.fixed_message_length:
|
|
243
|
+
chunk = sock.recv(provider.fixed_message_length - len(response_data))
|
|
244
|
+
if not chunk:
|
|
245
|
+
raise Exception("Connection closed while reading message")
|
|
246
|
+
response_data += chunk
|
|
247
|
+
|
|
248
|
+
return response_data
|
|
249
|
+
|
|
250
|
+
elif provider.framing_strategy == "stream":
|
|
251
|
+
# Read until connection closes or max_response_size is reached
|
|
252
|
+
response_data = b""
|
|
253
|
+
while len(response_data) < provider.max_response_size:
|
|
254
|
+
try:
|
|
255
|
+
chunk = sock.recv(min(4096, provider.max_response_size - len(response_data)))
|
|
256
|
+
if not chunk:
|
|
257
|
+
# Connection closed
|
|
258
|
+
break
|
|
259
|
+
response_data += chunk
|
|
260
|
+
except socket.timeout:
|
|
261
|
+
# Timeout reached
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
return response_data
|
|
265
|
+
|
|
266
|
+
else:
|
|
267
|
+
# Copilot AI (5 days ago):
|
|
268
|
+
# The else branch for unknown framing strategies was previously removed,
|
|
269
|
+
# which could cause silent fallthrough and confusing behavior. Add explicit
|
|
270
|
+
# validation to raise a descriptive error when an unsupported strategy is provided.
|
|
271
|
+
raise ValueError(f"Unknown framing strategy: {provider.framing_strategy!r}")
|
|
272
|
+
|
|
273
|
+
async def _send_tcp_message(
|
|
274
|
+
self,
|
|
275
|
+
host: str,
|
|
276
|
+
port: int,
|
|
277
|
+
message: str,
|
|
278
|
+
provider: TCPProvider,
|
|
279
|
+
timeout: float = 30.0,
|
|
280
|
+
response_encoding: Optional[str] = "utf-8"
|
|
281
|
+
) -> Union[str, bytes]:
|
|
282
|
+
"""Send a TCP message and wait for response.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
host: Host to connect to
|
|
286
|
+
port: Port to connect to
|
|
287
|
+
message: Message to send
|
|
288
|
+
provider: TCPProvider with framing configuration
|
|
289
|
+
timeout: Timeout in seconds
|
|
290
|
+
response_encoding: Encoding to decode response bytes. If None, returns raw bytes.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Response message or raw bytes if encoding is None
|
|
294
|
+
"""
|
|
295
|
+
loop = asyncio.get_event_loop()
|
|
296
|
+
|
|
297
|
+
def _send_and_receive():
|
|
298
|
+
"""Blocking function to send TCP message and receive response."""
|
|
299
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
300
|
+
try:
|
|
301
|
+
# Set connection timeout
|
|
302
|
+
sock.settimeout(timeout)
|
|
303
|
+
|
|
304
|
+
# Connect to server
|
|
305
|
+
sock.connect((host, port))
|
|
306
|
+
|
|
307
|
+
# Encode message with framing
|
|
308
|
+
framed_message = self._encode_message_with_framing(message, provider)
|
|
309
|
+
|
|
310
|
+
# Send message
|
|
311
|
+
sock.sendall(framed_message)
|
|
312
|
+
|
|
313
|
+
# Receive response based on framing strategy
|
|
314
|
+
response_bytes = self._decode_response_with_framing(sock, provider, timeout)
|
|
315
|
+
|
|
316
|
+
return response_bytes
|
|
317
|
+
|
|
318
|
+
except socket.timeout:
|
|
319
|
+
raise Exception(f"TCP connection timeout after {timeout} seconds")
|
|
320
|
+
except Exception as e:
|
|
321
|
+
raise Exception(f"TCP communication error: {e}")
|
|
322
|
+
finally:
|
|
323
|
+
sock.close()
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
# Run blocking socket operations in executor
|
|
327
|
+
response_bytes = await loop.run_in_executor(None, _send_and_receive)
|
|
328
|
+
|
|
329
|
+
# Return based on encoding preference
|
|
330
|
+
if response_encoding is None:
|
|
331
|
+
return response_bytes
|
|
332
|
+
else:
|
|
333
|
+
try:
|
|
334
|
+
return response_bytes.decode(response_encoding)
|
|
335
|
+
except UnicodeDecodeError as e:
|
|
336
|
+
self._log_error(f"Failed to decode response with encoding '{response_encoding}': {e}")
|
|
337
|
+
# Return raw bytes as fallback
|
|
338
|
+
return response_bytes
|
|
339
|
+
|
|
340
|
+
except Exception as e:
|
|
341
|
+
self._log_error(f"Error in TCP communication: {e}")
|
|
342
|
+
raise
|
|
343
|
+
|
|
344
|
+
async def register_manual(self, caller, manual_call_template: CallTemplate) -> RegisterManualResult:
|
|
345
|
+
"""Register a TCP manual and discover its tools."""
|
|
346
|
+
if not isinstance(manual_call_template, TCPProvider):
|
|
347
|
+
raise ValueError("TCPTransport can only be used with TCPProvider")
|
|
348
|
+
|
|
349
|
+
self._log_info(f"Registering TCP provider '{manual_call_template.name}'")
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
discovery_message = json.dumps({"type": "utcp"})
|
|
353
|
+
response = await self._send_tcp_message(
|
|
354
|
+
manual_call_template.host,
|
|
355
|
+
manual_call_template.port,
|
|
356
|
+
discovery_message,
|
|
357
|
+
manual_call_template,
|
|
358
|
+
manual_call_template.timeout / 1000.0,
|
|
359
|
+
manual_call_template.response_byte_format
|
|
360
|
+
)
|
|
361
|
+
try:
|
|
362
|
+
response_str = response.decode('utf-8') if isinstance(response, bytes) else response
|
|
363
|
+
response_data = json.loads(response_str)
|
|
364
|
+
tools: List[Tool] = []
|
|
365
|
+
if isinstance(response_data, dict) and 'tools' in response_data:
|
|
366
|
+
tools_data = response_data['tools']
|
|
367
|
+
for tool_data in tools_data:
|
|
368
|
+
try:
|
|
369
|
+
normalized = self._ensure_tool_call_template(tool_data, manual_call_template)
|
|
370
|
+
tools.append(Tool(**normalized))
|
|
371
|
+
except Exception as e:
|
|
372
|
+
self._log_error(f"Invalid tool definition in TCP provider '{manual_call_template.name}': {e}")
|
|
373
|
+
continue
|
|
374
|
+
self._log_info(f"Discovered {len(tools)} tools from TCP provider '{manual_call_template.name}'")
|
|
375
|
+
else:
|
|
376
|
+
self._log_info(f"No tools found in TCP provider '{manual_call_template.name}' response")
|
|
377
|
+
manual = UtcpManual(utcp_version="1.0", manual_version="1.0", tools=tools)
|
|
378
|
+
return RegisterManualResult(
|
|
379
|
+
manual_call_template=manual_call_template,
|
|
380
|
+
manual=manual,
|
|
381
|
+
success=True,
|
|
382
|
+
errors=[]
|
|
383
|
+
)
|
|
384
|
+
except json.JSONDecodeError as e:
|
|
385
|
+
self._log_error(f"Invalid JSON response from TCP provider '{manual_call_template.name}': {e}")
|
|
386
|
+
return RegisterManualResult(
|
|
387
|
+
manual_call_template=manual_call_template,
|
|
388
|
+
manual=UtcpManual(utcp_version="1.0", manual_version="1.0", tools=[]),
|
|
389
|
+
success=False,
|
|
390
|
+
errors=[str(e)]
|
|
391
|
+
)
|
|
392
|
+
except Exception as e:
|
|
393
|
+
self._log_error(f"Error registering TCP provider '{manual_call_template.name}': {e}")
|
|
394
|
+
return RegisterManualResult(
|
|
395
|
+
manual_call_template=manual_call_template,
|
|
396
|
+
manual=UtcpManual(utcp_version="1.0", manual_version="1.0", tools=[]),
|
|
397
|
+
success=False,
|
|
398
|
+
errors=[str(e)]
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
async def deregister_manual(self, caller, manual_call_template: CallTemplate) -> None:
|
|
402
|
+
"""Deregister a TCP provider (no-op)."""
|
|
403
|
+
if not isinstance(manual_call_template, TCPProvider):
|
|
404
|
+
raise ValueError("TCPTransport can only be used with TCPProvider")
|
|
405
|
+
self._log_info(f"Deregistering TCP provider '{manual_call_template.name}' (no-op)")
|
|
406
|
+
|
|
407
|
+
async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate):
|
|
408
|
+
async def _generator():
|
|
409
|
+
yield await self.call_tool(caller, tool_name, tool_args, tool_call_template)
|
|
410
|
+
return _generator()
|
|
411
|
+
|
|
412
|
+
async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any:
|
|
413
|
+
"""Call a TCP tool."""
|
|
414
|
+
if not isinstance(tool_call_template, TCPProvider):
|
|
415
|
+
raise ValueError("TCPTransport can only be used with TCPProvider")
|
|
416
|
+
|
|
417
|
+
self._log_info(f"Calling TCP tool '{tool_name}' on provider '{tool_call_template.name}'")
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
tool_call_message = self._format_tool_call_message(tool_args, tool_call_template)
|
|
421
|
+
|
|
422
|
+
response = await self._send_tcp_message(
|
|
423
|
+
tool_call_template.host,
|
|
424
|
+
tool_call_template.port,
|
|
425
|
+
tool_call_message,
|
|
426
|
+
tool_call_template,
|
|
427
|
+
tool_call_template.timeout / 1000.0,
|
|
428
|
+
tool_call_template.response_byte_format
|
|
429
|
+
)
|
|
430
|
+
return response
|
|
431
|
+
|
|
432
|
+
except Exception as e:
|
|
433
|
+
self._log_error(f"Error calling TCP tool '{tool_name}': {e}")
|
|
434
|
+
raise
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from utcp.data.call_template import CallTemplate
|
|
2
|
+
from typing import Optional, Literal
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from utcp.interfaces.serializer import Serializer
|
|
5
|
+
from utcp.exceptions import UtcpSerializerValidationError
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
class UDPProvider(CallTemplate):
|
|
9
|
+
"""Provider configuration for UDP (User Datagram Protocol) socket tools.
|
|
10
|
+
|
|
11
|
+
Enables communication with UDP servers using the connectionless UDP protocol.
|
|
12
|
+
Supports flexible request formatting, response decoding, and multi-datagram
|
|
13
|
+
response handling.
|
|
14
|
+
|
|
15
|
+
Request Data Handling:
|
|
16
|
+
- 'json' format: Arguments formatted as JSON object
|
|
17
|
+
- 'text' format: Template-based with UTCP_ARG_argname_UTCP_ARG placeholders
|
|
18
|
+
|
|
19
|
+
Response Data Handling:
|
|
20
|
+
- If response_byte_format is None: Returns raw bytes
|
|
21
|
+
- If response_byte_format is encoding string: Decodes bytes to text
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
call_template_type: Always "udp" for UDP providers.
|
|
25
|
+
host: The hostname or IP address of the UDP server.
|
|
26
|
+
port: The port number of the UDP server.
|
|
27
|
+
number_of_response_datagrams: Expected number of response datagrams (0 for no response).
|
|
28
|
+
request_data_format: Format for request data ('json' or 'text').
|
|
29
|
+
request_data_template: Template string for 'text' format with placeholders.
|
|
30
|
+
response_byte_format: Encoding for response decoding (None for raw bytes).
|
|
31
|
+
timeout: Request timeout in milliseconds.
|
|
32
|
+
auth: Always None - UDP providers don't support authentication.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
call_template_type: Literal["udp"] = "udp"
|
|
36
|
+
host: str
|
|
37
|
+
port: int
|
|
38
|
+
number_of_response_datagrams: int = 1
|
|
39
|
+
request_data_format: Literal["json", "text"] = "json"
|
|
40
|
+
request_data_template: Optional[str] = None
|
|
41
|
+
response_byte_format: Optional[str] = Field(default="utf-8", description="Encoding to decode response bytes. If None, returns raw bytes.")
|
|
42
|
+
timeout: int = 30000
|
|
43
|
+
auth: None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UDPProviderSerializer(Serializer[UDPProvider]):
|
|
47
|
+
def to_dict(self, obj: UDPProvider) -> dict:
|
|
48
|
+
return obj.model_dump()
|
|
49
|
+
|
|
50
|
+
def validate_dict(self, data: dict) -> UDPProvider:
|
|
51
|
+
try:
|
|
52
|
+
return UDPProvider.model_validate(data)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
raise UtcpSerializerValidationError(
|
|
55
|
+
f"Invalid UDPProvider: {e}\n{traceback.format_exc()}"
|
|
56
|
+
)
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""
|
|
2
|
+
User Datagram Protocol (UDP) transport for UTCP client.
|
|
3
|
+
|
|
4
|
+
This transport communicates with tools over UDP sockets.
|
|
5
|
+
"""
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import socket
|
|
9
|
+
import traceback
|
|
10
|
+
from typing import Dict, Any, List, Optional, Callable, Union
|
|
11
|
+
|
|
12
|
+
from utcp.interfaces.communication_protocol import CommunicationProtocol
|
|
13
|
+
from utcp_socket.udp_call_template import UDPProvider, UDPProviderSerializer
|
|
14
|
+
from utcp.data.tool import Tool
|
|
15
|
+
from utcp.data.call_template import CallTemplate, CallTemplateSerializer
|
|
16
|
+
from utcp.data.register_manual_response import RegisterManualResult
|
|
17
|
+
from utcp.data.utcp_manual import UtcpManual
|
|
18
|
+
from utcp.exceptions import UtcpSerializerValidationError
|
|
19
|
+
import logging
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
class UDPTransport(CommunicationProtocol):
|
|
24
|
+
"""Transport implementation for UDP-based tool providers.
|
|
25
|
+
|
|
26
|
+
This transport communicates with tools over UDP sockets. It supports:
|
|
27
|
+
- Tool discovery via UDP messages
|
|
28
|
+
- Tool execution by sending UDP packets with arguments
|
|
29
|
+
- Multiple response datagrams handling
|
|
30
|
+
- JSON and text-based request formatting
|
|
31
|
+
- Template-based argument substitution
|
|
32
|
+
- Configurable response byte format (text encoding or raw bytes)
|
|
33
|
+
- Stateless operation (no persistent connections)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, logger: Optional[Callable[[str], None]] = None):
|
|
37
|
+
"""Initialize the UDP transport.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
logger: Optional logger function for debugging
|
|
41
|
+
"""
|
|
42
|
+
self._log = logger or (lambda *args, **kwargs: None)
|
|
43
|
+
# UDP is stateless, so no connections to manage
|
|
44
|
+
|
|
45
|
+
def _log_info(self, message: str):
|
|
46
|
+
"""Log informational messages."""
|
|
47
|
+
self._log(f"[UDPTransport] {message}")
|
|
48
|
+
|
|
49
|
+
def _log_error(self, message: str):
|
|
50
|
+
"""Log error messages."""
|
|
51
|
+
logger.error(f"[UDPTransport Error] {message}")
|
|
52
|
+
|
|
53
|
+
def _format_tool_call_message(
|
|
54
|
+
self,
|
|
55
|
+
tool_args: Dict[str, Any],
|
|
56
|
+
provider: UDPProvider
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Format a tool call message based on provider configuration.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
tool_args: Arguments for the tool call
|
|
62
|
+
provider: The UDPProvider with formatting configuration
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Formatted message string
|
|
66
|
+
"""
|
|
67
|
+
if provider.request_data_format == "json":
|
|
68
|
+
return json.dumps(tool_args)
|
|
69
|
+
elif provider.request_data_format == "text":
|
|
70
|
+
# Use template-based formatting
|
|
71
|
+
if provider.request_data_template is not None and provider.request_data_template != "":
|
|
72
|
+
message = provider.request_data_template
|
|
73
|
+
# Replace placeholders with argument values
|
|
74
|
+
for arg_name, arg_value in tool_args.items():
|
|
75
|
+
placeholder = f"UTCP_ARG_{arg_name}_UTCP_ARG"
|
|
76
|
+
if isinstance(arg_value, str):
|
|
77
|
+
message = message.replace(placeholder, arg_value)
|
|
78
|
+
else:
|
|
79
|
+
message = message.replace(placeholder, json.dumps(arg_value))
|
|
80
|
+
return message
|
|
81
|
+
else:
|
|
82
|
+
# Fallback to simple key=value format
|
|
83
|
+
return " ".join([str(v) for k, v in tool_args.items()])
|
|
84
|
+
else:
|
|
85
|
+
# Default to JSON format
|
|
86
|
+
return json.dumps(tool_args)
|
|
87
|
+
|
|
88
|
+
def _ensure_tool_call_template(self, tool_data: Dict[str, Any], manual_call_template: UDPProvider) -> Dict[str, Any]:
|
|
89
|
+
"""Normalize tool definition to include a valid 'tool_call_template'.
|
|
90
|
+
|
|
91
|
+
- If 'tool_call_template' exists, validate it.
|
|
92
|
+
- Else if legacy 'tool_provider' exists, convert using UDPProviderSerializer.
|
|
93
|
+
- Else default to the provided manual_call_template.
|
|
94
|
+
"""
|
|
95
|
+
normalized = dict(tool_data)
|
|
96
|
+
try:
|
|
97
|
+
if "tool_call_template" in normalized and normalized["tool_call_template"] is not None:
|
|
98
|
+
# Validate via generic CallTemplate serializer (type-dispatched)
|
|
99
|
+
try:
|
|
100
|
+
ctpl = CallTemplateSerializer().validate_dict(normalized["tool_call_template"]) # type: ignore
|
|
101
|
+
normalized["tool_call_template"] = ctpl
|
|
102
|
+
except (UtcpSerializerValidationError, ValueError) as e:
|
|
103
|
+
# Fallback to manual template if validation fails, but log details
|
|
104
|
+
logger.exception("Failed to validate existing tool_call_template; falling back to manual template")
|
|
105
|
+
normalized["tool_call_template"] = manual_call_template
|
|
106
|
+
elif "tool_provider" in normalized and normalized["tool_provider"] is not None:
|
|
107
|
+
# Convert legacy provider -> call template
|
|
108
|
+
try:
|
|
109
|
+
ctpl = UDPProviderSerializer().validate_dict(normalized["tool_provider"]) # type: ignore
|
|
110
|
+
normalized.pop("tool_provider", None)
|
|
111
|
+
normalized["tool_call_template"] = ctpl
|
|
112
|
+
except UtcpSerializerValidationError as e:
|
|
113
|
+
logger.exception("Failed to convert legacy tool_provider to call template; falling back to manual template")
|
|
114
|
+
normalized.pop("tool_provider", None)
|
|
115
|
+
normalized["tool_call_template"] = manual_call_template
|
|
116
|
+
else:
|
|
117
|
+
normalized["tool_call_template"] = manual_call_template
|
|
118
|
+
except Exception:
|
|
119
|
+
# Any unexpected error during normalization should be logged
|
|
120
|
+
logger.exception("Unexpected error normalizing tool definition; falling back to manual template")
|
|
121
|
+
normalized["tool_call_template"] = manual_call_template
|
|
122
|
+
return normalized
|
|
123
|
+
|
|
124
|
+
async def _send_udp_message(
|
|
125
|
+
self,
|
|
126
|
+
host: str,
|
|
127
|
+
port: int,
|
|
128
|
+
message: str,
|
|
129
|
+
timeout: float = 30.0,
|
|
130
|
+
num_response_datagrams: int = 1,
|
|
131
|
+
response_encoding: Optional[str] = "utf-8"
|
|
132
|
+
) -> Union[str, bytes]:
|
|
133
|
+
"""Send a UDP message and wait for response(s).
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
host: Host to send message to
|
|
137
|
+
port: Port to send message to
|
|
138
|
+
message: Message to send
|
|
139
|
+
timeout: Timeout in seconds
|
|
140
|
+
num_response_datagrams: Number of response datagrams to receive
|
|
141
|
+
response_encoding: Encoding to decode response bytes. If None, returns raw bytes.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Response message (concatenated if multiple datagrams) or raw bytes if encoding is None
|
|
145
|
+
"""
|
|
146
|
+
if num_response_datagrams == 0:
|
|
147
|
+
# No response expected - just send and return
|
|
148
|
+
await self._send_udp_no_response(host, port, message)
|
|
149
|
+
return b"" if response_encoding is None else ""
|
|
150
|
+
|
|
151
|
+
# Use simple socket approach with executor for Windows compatibility
|
|
152
|
+
loop = asyncio.get_event_loop()
|
|
153
|
+
|
|
154
|
+
def _send_and_receive():
|
|
155
|
+
"""Blocking function to send UDP message and receive responses."""
|
|
156
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
157
|
+
try:
|
|
158
|
+
# Resolve host to IP for comparison
|
|
159
|
+
try:
|
|
160
|
+
resolved_host_ip = socket.gethostbyname(host)
|
|
161
|
+
except socket.gaierror:
|
|
162
|
+
resolved_host_ip = host # Fallback to original if resolution fails
|
|
163
|
+
|
|
164
|
+
# Send message
|
|
165
|
+
message_bytes = message.encode('utf-8')
|
|
166
|
+
sock.sendto(message_bytes, (host, port))
|
|
167
|
+
|
|
168
|
+
# Collect responses
|
|
169
|
+
response_bytes_list = []
|
|
170
|
+
|
|
171
|
+
for i in range(max(1, num_response_datagrams)):
|
|
172
|
+
try:
|
|
173
|
+
# Use shorter timeout for subsequent datagrams
|
|
174
|
+
current_timeout = timeout if i == 0 else 1.0
|
|
175
|
+
|
|
176
|
+
# Set socket timeout
|
|
177
|
+
sock.settimeout(current_timeout)
|
|
178
|
+
|
|
179
|
+
# Receive response
|
|
180
|
+
data, addr = sock.recvfrom(65535)
|
|
181
|
+
|
|
182
|
+
# Verify it's from the expected host (compare with resolved IP)
|
|
183
|
+
if addr[0] == host or addr[0] == resolved_host_ip:
|
|
184
|
+
response_bytes_list.append(data)
|
|
185
|
+
else:
|
|
186
|
+
# Got response from wrong host, don't count it
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
except socket.timeout:
|
|
190
|
+
if i == 0:
|
|
191
|
+
# First datagram timed out
|
|
192
|
+
raise TimeoutError(f"UDP request timed out after {timeout} seconds")
|
|
193
|
+
else:
|
|
194
|
+
# Subsequent datagrams timed out, but we have some data
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
return response_bytes_list
|
|
198
|
+
|
|
199
|
+
finally:
|
|
200
|
+
sock.close()
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
# Run blocking socket operations in executor
|
|
204
|
+
response_bytes_list = await loop.run_in_executor(None, _send_and_receive)
|
|
205
|
+
|
|
206
|
+
# Concatenate response bytes
|
|
207
|
+
combined_bytes = b''.join(response_bytes_list)
|
|
208
|
+
|
|
209
|
+
# Return based on encoding preference
|
|
210
|
+
if response_encoding is None:
|
|
211
|
+
return combined_bytes
|
|
212
|
+
else:
|
|
213
|
+
try:
|
|
214
|
+
return combined_bytes.decode(response_encoding)
|
|
215
|
+
except UnicodeDecodeError as e:
|
|
216
|
+
self._log_error(f"Failed to decode response with encoding '{response_encoding}': {e}")
|
|
217
|
+
# Return raw bytes as fallback
|
|
218
|
+
return combined_bytes
|
|
219
|
+
|
|
220
|
+
except TimeoutError as e:
|
|
221
|
+
self._log_error(traceback.format_exc())
|
|
222
|
+
raise asyncio.TimeoutError(traceback.format_exc())
|
|
223
|
+
except Exception as e:
|
|
224
|
+
self._log_error(f"Error sending UDP message: {traceback.format_exc()}")
|
|
225
|
+
raise
|
|
226
|
+
|
|
227
|
+
async def _send_udp_no_response(self, host: str, port: int, message: str) -> None:
|
|
228
|
+
"""Send a UDP message without expecting a response."""
|
|
229
|
+
def _send_only():
|
|
230
|
+
"""Blocking function to send UDP message only."""
|
|
231
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
232
|
+
try:
|
|
233
|
+
message_bytes = message.encode('utf-8')
|
|
234
|
+
sock.sendto(message_bytes, (host, port))
|
|
235
|
+
finally:
|
|
236
|
+
sock.close()
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
loop = asyncio.get_event_loop()
|
|
240
|
+
await loop.run_in_executor(None, _send_only)
|
|
241
|
+
except Exception as e:
|
|
242
|
+
self._log_error(f"Error sending UDP message (no response): {traceback.format_exc()}")
|
|
243
|
+
raise
|
|
244
|
+
|
|
245
|
+
async def register_manual(self, caller, manual_call_template: CallTemplate) -> RegisterManualResult:
|
|
246
|
+
"""Register a UDP manual and discover its tools."""
|
|
247
|
+
if not isinstance(manual_call_template, UDPProvider):
|
|
248
|
+
raise ValueError("UDPTransport can only be used with UDPProvider")
|
|
249
|
+
|
|
250
|
+
self._log_info(f"Registering UDP provider '{manual_call_template.name}' at {manual_call_template.host}:{manual_call_template.port}")
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
discovery_message = json.dumps({"type": "utcp"})
|
|
254
|
+
response = await self._send_udp_message(
|
|
255
|
+
manual_call_template.host,
|
|
256
|
+
manual_call_template.port,
|
|
257
|
+
discovery_message,
|
|
258
|
+
manual_call_template.timeout / 1000.0,
|
|
259
|
+
manual_call_template.number_of_response_datagrams,
|
|
260
|
+
manual_call_template.response_byte_format
|
|
261
|
+
)
|
|
262
|
+
try:
|
|
263
|
+
response_str = response.decode('utf-8') if isinstance(response, bytes) else response
|
|
264
|
+
response_data = json.loads(response_str)
|
|
265
|
+
tools: List[Tool] = []
|
|
266
|
+
if isinstance(response_data, dict) and 'tools' in response_data:
|
|
267
|
+
tools_data = response_data['tools']
|
|
268
|
+
for tool_data in tools_data:
|
|
269
|
+
try:
|
|
270
|
+
normalized = self._ensure_tool_call_template(tool_data, manual_call_template)
|
|
271
|
+
tool = Tool(**normalized)
|
|
272
|
+
tools.append(tool)
|
|
273
|
+
except Exception:
|
|
274
|
+
self._log_error(f"Invalid tool definition in UDP provider '{manual_call_template.name}': {traceback.format_exc()}")
|
|
275
|
+
continue
|
|
276
|
+
self._log_info(f"Discovered {len(tools)} tools from UDP provider '{manual_call_template.name}'")
|
|
277
|
+
else:
|
|
278
|
+
self._log_info(f"No tools found in UDP provider '{manual_call_template.name}' response")
|
|
279
|
+
manual = UtcpManual(utcp_version="1.0", manual_version="1.0", tools=tools)
|
|
280
|
+
return RegisterManualResult(
|
|
281
|
+
manual_call_template=manual_call_template,
|
|
282
|
+
manual=manual,
|
|
283
|
+
success=True,
|
|
284
|
+
errors=[]
|
|
285
|
+
)
|
|
286
|
+
except json.JSONDecodeError as e:
|
|
287
|
+
self._log_error(f"Invalid JSON response from UDP provider '{manual_call_template.name}': {traceback.format_exc()}")
|
|
288
|
+
manual = UtcpManual(utcp_version="1.0", manual_version="1.0", tools=[])
|
|
289
|
+
return RegisterManualResult(
|
|
290
|
+
manual_call_template=manual_call_template,
|
|
291
|
+
manual=manual,
|
|
292
|
+
success=False,
|
|
293
|
+
errors=[str(e)]
|
|
294
|
+
)
|
|
295
|
+
except Exception as e:
|
|
296
|
+
self._log_error(f"Error registering UDP provider '{manual_call_template.name}': {traceback.format_exc()}")
|
|
297
|
+
manual = UtcpManual(utcp_version="1.0", manual_version="1.0", tools=[])
|
|
298
|
+
return RegisterManualResult(
|
|
299
|
+
manual_call_template=manual_call_template,
|
|
300
|
+
manual=manual,
|
|
301
|
+
success=False,
|
|
302
|
+
errors=[str(e)]
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
async def deregister_manual(self, caller, manual_call_template: CallTemplate) -> None:
|
|
306
|
+
if not isinstance(manual_call_template, UDPProvider):
|
|
307
|
+
raise ValueError("UDPTransport can only be used with UDPProvider")
|
|
308
|
+
self._log_info(f"Deregistering UDP provider '{manual_call_template.name}' (no-op)")
|
|
309
|
+
|
|
310
|
+
async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any:
|
|
311
|
+
if not isinstance(tool_call_template, UDPProvider):
|
|
312
|
+
raise ValueError("UDPTransport can only be used with UDPProvider")
|
|
313
|
+
self._log_info(f"Calling UDP tool '{tool_name}' on provider '{tool_call_template.name}'")
|
|
314
|
+
try:
|
|
315
|
+
tool_call_message = self._format_tool_call_message(tool_args, tool_call_template)
|
|
316
|
+
response = await self._send_udp_message(
|
|
317
|
+
tool_call_template.host,
|
|
318
|
+
tool_call_template.port,
|
|
319
|
+
tool_call_message,
|
|
320
|
+
tool_call_template.timeout / 1000.0,
|
|
321
|
+
tool_call_template.number_of_response_datagrams,
|
|
322
|
+
tool_call_template.response_byte_format
|
|
323
|
+
)
|
|
324
|
+
return response
|
|
325
|
+
except Exception as e:
|
|
326
|
+
self._log_error(f"Error calling UDP tool '{tool_name}': {traceback.format_exc()}")
|
|
327
|
+
raise
|
|
328
|
+
|
|
329
|
+
# Copilot AI (5 days ago):
|
|
330
|
+
# The call_tool_streaming method wraps a generator function but doesn't use the async def syntax for the method itself.
|
|
331
|
+
# While this works, it's inconsistent with the other implementation in tcp_communication_protocol.py (lines 384-387) which properly uses async def with an inner generator.
|
|
332
|
+
# For consistency and clarity, this should also use async def directly:
|
|
333
|
+
#
|
|
334
|
+
# async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate):
|
|
335
|
+
# yield await self.call_tool(caller, tool_name, tool_args, tool_call_template)
|
|
336
|
+
async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate):
|
|
337
|
+
yield await self.call_tool(caller, tool_name, tool_args, tool_call_template)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: utcp-socket
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: UTCP communication protocol plugin for TCP and UDP protocols. (Work in progress)
|
|
5
|
+
Author: UTCP Contributors
|
|
6
|
+
License-Expression: MPL-2.0
|
|
7
|
+
Project-URL: Homepage, https://utcp.io
|
|
8
|
+
Project-URL: Source, https://github.com/universal-tool-calling-protocol/python-utcp
|
|
9
|
+
Project-URL: Issues, https://github.com/universal-tool-calling-protocol/python-utcp/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: pydantic>=2.0
|
|
17
|
+
Requires-Dist: utcp>=1.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: build; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
23
|
+
Requires-Dist: coverage; extra == "dev"
|
|
24
|
+
Requires-Dist: twine; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# UTCP Socket Plugin (UDP/TCP)
|
|
27
|
+
|
|
28
|
+
This plugin adds UDP and TCP communication protocols to UTCP 1.0.
|
|
29
|
+
|
|
30
|
+
## Running Tests
|
|
31
|
+
|
|
32
|
+
Prerequisites:
|
|
33
|
+
- Python 3.10+
|
|
34
|
+
- `pip`
|
|
35
|
+
- (Optional) a virtual environment
|
|
36
|
+
|
|
37
|
+
1) Install core and the socket plugin in editable mode with dev extras:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install -e "./core[dev]"
|
|
41
|
+
pip install -e ./plugins/communication_protocols/socket[dev]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
2) Run the socket plugin tests:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python -m pytest plugins/communication_protocols/socket/tests -v
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
3) Run a single test or filter by keyword:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# One file
|
|
54
|
+
python -m pytest plugins/communication_protocols/socket/tests/test_tcp_communication_protocol.py -v
|
|
55
|
+
|
|
56
|
+
# Filter by keyword (e.g., delimiter framing)
|
|
57
|
+
python -m pytest plugins/communication_protocols/socket/tests -k delimiter -q
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
4) Optional end-to-end sanity check (mock UDP/TCP servers):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python scripts/socket_sanity.py
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Notes:
|
|
67
|
+
- On Windows, your firewall may prompt the first time tests open UDP/TCP sockets; allow access or run as admin if needed.
|
|
68
|
+
- Tests use `pytest-asyncio`. The dev extras installed above provide required dependencies.
|
|
69
|
+
- Streaming is single-chunk by design, consistent with HTTP/Text transports. Multi-chunk streaming can be added later behind provider configuration.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
utcp_socket/__init__.py,sha256=KEFMWm3BXEiCtw60ICqwMvzzNMsSlLbc1BxtvSnFsiI,742
|
|
2
|
+
utcp_socket/tcp_call_template.py,sha256=8DdoLO3eIskWdIqF0Y-zhaWoD1uPFRN2VZq6h88bdlc,4600
|
|
3
|
+
utcp_socket/tcp_communication_protocol.py,sha256=jcg6WwSQGnOaMAZuIS3_17UqvgCLGC31bNMWUeP_s_c,20481
|
|
4
|
+
utcp_socket/udp_call_template.py,sha256=ibXMAGb9qTeLkqhmTi3PDrnqxwB5bTH-l-Ut43_6j_A,2419
|
|
5
|
+
utcp_socket/udp_communication_protocol.py,sha256=j2IuqMsX8TrJzySyp1FtAozA5aCUTv8VbL7cFrlfowQ,16592
|
|
6
|
+
utcp_socket-1.0.2.dist-info/METADATA,sha256=N4DAeOgjJn8hv369NGdJo7Cx3w4Jty3u5WB6OapKs2I,2290
|
|
7
|
+
utcp_socket-1.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
utcp_socket-1.0.2.dist-info/entry_points.txt,sha256=Y2Q2PwwL-B6ODwaWEohMtrpipKvRBg3AYgE-s6Toalk,45
|
|
9
|
+
utcp_socket-1.0.2.dist-info/top_level.txt,sha256=Tys91lBOENWnB_K59d1xKKwJgaB5UZRx8BITQ5szn4I,12
|
|
10
|
+
utcp_socket-1.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
utcp_socket
|